claude-dev-env 8.12.0 → 8.13.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.
- package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
- package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
- package/_shared/advisor/reference/astra-rung.md +7 -7
- package/_shared/advisor/reference/third-party-bind.md +2 -2
- package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
- package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
- package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
- package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
- package/_shared/pr-loop/scripts/check_convergence.py +54 -49
- package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
- package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
- package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
- package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
- package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
- package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
- package/bin/git_hooks_installer.test.mjs +1 -1
- package/bin/install-constants.mjs +1 -6
- package/bin/install.cursor-rules.test.mjs +24 -51
- package/bin/install.mjs +16 -70
- package/docs/codex-accounts.md +79 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
- package/hooks/blocking/test_pii_scanner.py +1 -1
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
- package/hooks/git-hooks/conftest.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +1 -1
- package/hooks/git-hooks/test_pre_commit.py +1 -1
- package/hooks/git-hooks/test_pre_push.py +2 -2
- package/hooks/git-hooks/test_verification_start.py +1 -1
- package/hooks/git-hooks/verification_notice.py +2 -2
- package/hooks/git-hooks/verification_notice_context.py +21 -2
- package/hooks/git-hooks/verification_start.py +2 -3
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +4 -3
- package/scripts/claude_account_profile.py +21 -9
- package/scripts/codex_account_choice.py +396 -0
- package/scripts/codex_account_meters.py +372 -0
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
- package/scripts/private_term_scan.py +6 -2
- package/scripts/repository_checks/config/constants.py +1 -0
- package/scripts/repository_checks/tracked_private_terms.py +7 -0
- package/scripts/test_claude_account_profile.py +12 -0
- package/scripts/test_codex_account_choice.py +276 -0
- package/scripts/test_codex_account_meters.py +205 -0
- package/scripts/tests/test_automatic_advisory.py +3 -3
- package/scripts/tests/test_automatic_advisory_polling.py +1 -1
- package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
- package/scripts/tests/test_closed_pr_label.py +1 -1
- package/scripts/tests/test_private_term_scan.py +28 -0
- package/scripts/tests/test_review_closure.py +2 -2
- package/scripts/tests/test_review_closure_model.py +2 -2
- package/scripts/tests/test_rules.py +2 -83
- package/scripts/tests/test_tracked_private_terms.py +15 -0
- package/scripts/tests/test_verification_notice.py +29 -13
- package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
- package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
- package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
- package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
- package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
- package/scripts/sync_to_cursor/AGENTS.md +0 -1
- package/scripts/sync_to_cursor/CLAUDE.md +0 -1
- package/scripts/sync_to_cursor/__init__.py +0 -13
- package/scripts/sync_to_cursor/canonical_docs.py +0 -66
- package/scripts/sync_to_cursor/config/__init__.py +0 -8
- package/scripts/sync_to_cursor/engine.py +0 -219
- package/scripts/sync_to_cursor/hashing.py +0 -7
- package/scripts/sync_to_cursor/paths.py +0 -18
- package/scripts/sync_to_cursor/rules.py +0 -494
- package/scripts/sync_to_cursor.py +0 -22
- package/scripts/tests/test_engine.py +0 -101
- package/scripts/tests/test_sync_to_cursor.py +0 -315
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read one Codex account's rate-limit windows from its own Codex home.
|
|
3
|
+
|
|
4
|
+
Codex reports usage through ``codex app-server`` and the JSON-RPC method
|
|
5
|
+
``account/rateLimits/read``. The reply names up to two windows, a short one
|
|
6
|
+
(5 hours) and a weekly one. The account's room is the smaller of the two::
|
|
7
|
+
|
|
8
|
+
5-hour window 30% used, week 85% used -> 15% left
|
|
9
|
+
week window only, 40% used -> 60% left
|
|
10
|
+
no window, error reply, no reply -> CodexMeterUnreadError
|
|
11
|
+
|
|
12
|
+
Every failure raises ``CodexMeterUnreadError``. The caller decides what an
|
|
13
|
+
unread account means; this module never guesses a number.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import IO
|
|
31
|
+
|
|
32
|
+
from dev_env_scripts_constants.codex_account_constants import (
|
|
33
|
+
ALL_APP_SERVER_ARGUMENTS,
|
|
34
|
+
ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS,
|
|
35
|
+
ALL_WINDOW_KEYS,
|
|
36
|
+
APP_SERVER_TIMEOUT_SECONDS,
|
|
37
|
+
CLIENT_NAME,
|
|
38
|
+
CLIENT_VERSION,
|
|
39
|
+
CODEX_BINARY_NAME,
|
|
40
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
41
|
+
FULL_PERCENT,
|
|
42
|
+
INITIALIZE_REQUEST_ID,
|
|
43
|
+
JSONRPC_VERSION,
|
|
44
|
+
METHOD_INITIALIZE,
|
|
45
|
+
METHOD_INITIALIZED,
|
|
46
|
+
METHOD_RATE_LIMITS_READ,
|
|
47
|
+
PROCESS_WAIT_TIMEOUT_SECONDS,
|
|
48
|
+
RATE_LIMITS_REQUEST_ID,
|
|
49
|
+
READER_JOIN_TIMEOUT_SECONDS,
|
|
50
|
+
TEXT_ENCODING,
|
|
51
|
+
WEEKLY_WINDOW_MINUTES,
|
|
52
|
+
)
|
|
53
|
+
from shared_tree_paths import resolve_shared_process_tree_scripts_directory
|
|
54
|
+
|
|
55
|
+
_shared_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
|
|
56
|
+
__file__,
|
|
57
|
+
all_environment=os.environ,
|
|
58
|
+
)
|
|
59
|
+
if str(_shared_process_tree_scripts_directory) not in sys.path:
|
|
60
|
+
sys.path.insert(0, str(_shared_process_tree_scripts_directory))
|
|
61
|
+
|
|
62
|
+
_process_tree_kill = importlib.import_module("process_tree_kill")
|
|
63
|
+
_subprocess_window_access = importlib.import_module("subprocess_window_access")
|
|
64
|
+
|
|
65
|
+
ServerExchange = Callable[[Path, Path, Sequence[Mapping[str, object]]], list[str]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CodexMeterUnreadError(Exception):
|
|
69
|
+
"""Raised when one account's rate-limit windows cannot be read."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class UsageWindow:
|
|
74
|
+
"""One rate-limit window: its length, the percent used, and when it resets."""
|
|
75
|
+
|
|
76
|
+
duration_minutes: int | None
|
|
77
|
+
used_percent: float
|
|
78
|
+
resets_at: datetime | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class CodexAccountMeters:
|
|
83
|
+
"""One account's windows, with percent_left, short_window_percent_left and resets_before_room."""
|
|
84
|
+
|
|
85
|
+
all_windows: tuple[UsageWindow, ...]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def percent_left(self) -> float:
|
|
89
|
+
"""Room left in the tightest window, from 0 to 100."""
|
|
90
|
+
most_used = max(each_window.used_percent for each_window in self.all_windows)
|
|
91
|
+
return max(0.0, FULL_PERCENT - most_used)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def short_window_percent_left(self) -> float | None:
|
|
95
|
+
"""Room left in the windows shorter than a week, or None when the account has none."""
|
|
96
|
+
all_short_used = [
|
|
97
|
+
each_window.used_percent
|
|
98
|
+
for each_window in self.all_windows
|
|
99
|
+
if each_window.duration_minutes is not None
|
|
100
|
+
and each_window.duration_minutes < WEEKLY_WINDOW_MINUTES
|
|
101
|
+
]
|
|
102
|
+
if not all_short_used:
|
|
103
|
+
return None
|
|
104
|
+
return max(0.0, FULL_PERCENT - max(all_short_used))
|
|
105
|
+
|
|
106
|
+
def resets_before_room(self, minimum_percent_left: float) -> datetime | None:
|
|
107
|
+
"""When every window that holds the account at or under a bar has reset.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
minimum_percent_left: The percent left the account must pass.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The latest reset among the blocking windows, or None when one
|
|
114
|
+
carries no reset time or none blocks.
|
|
115
|
+
"""
|
|
116
|
+
all_blocking = [
|
|
117
|
+
each_window
|
|
118
|
+
for each_window in self.all_windows
|
|
119
|
+
if FULL_PERCENT - each_window.used_percent <= minimum_percent_left
|
|
120
|
+
]
|
|
121
|
+
if not all_blocking or any(
|
|
122
|
+
each_window.resets_at is None for each_window in all_blocking
|
|
123
|
+
):
|
|
124
|
+
return None
|
|
125
|
+
return max(
|
|
126
|
+
each_window.resets_at
|
|
127
|
+
for each_window in all_blocking
|
|
128
|
+
if each_window.resets_at is not None
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _finite_number(raw_number: object) -> float | None:
|
|
133
|
+
if isinstance(raw_number, bool) or not isinstance(raw_number, (int, float)):
|
|
134
|
+
return None
|
|
135
|
+
if not math.isfinite(raw_number):
|
|
136
|
+
return None
|
|
137
|
+
return float(raw_number)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_resets_at(raw_resets_at: object) -> datetime | None:
|
|
141
|
+
seconds = _finite_number(raw_resets_at)
|
|
142
|
+
if seconds is None:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
146
|
+
except (OverflowError, OSError, ValueError):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_window(raw_window: object) -> UsageWindow | None:
|
|
151
|
+
if not isinstance(raw_window, Mapping):
|
|
152
|
+
return None
|
|
153
|
+
used_percent = _finite_number(raw_window.get("usedPercent"))
|
|
154
|
+
if used_percent is None:
|
|
155
|
+
return None
|
|
156
|
+
duration = _finite_number(raw_window.get("windowDurationMins"))
|
|
157
|
+
return UsageWindow(
|
|
158
|
+
duration_minutes=None if duration is None else int(duration),
|
|
159
|
+
used_percent=min(FULL_PERCENT, max(0.0, used_percent)),
|
|
160
|
+
resets_at=_parse_resets_at(raw_window.get("resetsAt")),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
|
|
165
|
+
try:
|
|
166
|
+
reply = json.loads(reply_text)
|
|
167
|
+
except json.JSONDecodeError as error:
|
|
168
|
+
raise CodexMeterUnreadError("rate-limit reply is not JSON") from error
|
|
169
|
+
if not isinstance(reply, dict):
|
|
170
|
+
raise CodexMeterUnreadError("rate-limit reply is not an object")
|
|
171
|
+
if reply.get("error") is not None:
|
|
172
|
+
raise CodexMeterUnreadError(f"rate-limit read failed: {reply['error']}")
|
|
173
|
+
body = reply.get("result")
|
|
174
|
+
rate_limits = body.get("rateLimits") if isinstance(body, dict) else None
|
|
175
|
+
if not isinstance(rate_limits, dict):
|
|
176
|
+
raise CodexMeterUnreadError("rate-limit reply names no rateLimits")
|
|
177
|
+
return rate_limits
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
|
|
181
|
+
"""Parse the server's reply to the rate-limit read.
|
|
182
|
+
|
|
183
|
+
::
|
|
184
|
+
|
|
185
|
+
{"id": 2, "result": {"rateLimits": {"primary": {"usedPercent": 30, ...},
|
|
186
|
+
"secondary": {"usedPercent": 85, ...}}}}
|
|
187
|
+
-> CodexAccountMeters with two windows, 15% left
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
reply_text: One line the ``codex app-server`` process wrote.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The windows the reply carries.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
CodexMeterUnreadError: The line is no reply, an error reply, or names
|
|
197
|
+
no window with a used percent.
|
|
198
|
+
"""
|
|
199
|
+
rate_limits = _rate_limits_mapping(reply_text)
|
|
200
|
+
all_windows = tuple(
|
|
201
|
+
each_window
|
|
202
|
+
for each_key in ALL_WINDOW_KEYS
|
|
203
|
+
if (each_window := _parse_window(rate_limits.get(each_key))) is not None
|
|
204
|
+
)
|
|
205
|
+
if not all_windows:
|
|
206
|
+
raise CodexMeterUnreadError("rate-limit reply names no usage window")
|
|
207
|
+
return CodexAccountMeters(all_windows=all_windows)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def request_messages() -> list[dict[str, object]]:
|
|
211
|
+
"""Build the handshake and the rate-limit read, in send order.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The initialize request, the initialized notice, and the read request.
|
|
215
|
+
"""
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
219
|
+
"id": INITIALIZE_REQUEST_ID,
|
|
220
|
+
"method": METHOD_INITIALIZE,
|
|
221
|
+
"params": {
|
|
222
|
+
"clientInfo": {"name": CLIENT_NAME, "version": CLIENT_VERSION},
|
|
223
|
+
"capabilities": {"experimentalApi": True},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{"jsonrpc": JSONRPC_VERSION, "method": METHOD_INITIALIZED, "params": {}},
|
|
227
|
+
{
|
|
228
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
229
|
+
"id": RATE_LIMITS_REQUEST_ID,
|
|
230
|
+
"method": METHOD_RATE_LIMITS_READ,
|
|
231
|
+
"params": {},
|
|
232
|
+
},
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _is_rate_limits_reply(line: str) -> bool:
|
|
237
|
+
try:
|
|
238
|
+
message = json.loads(line)
|
|
239
|
+
except json.JSONDecodeError:
|
|
240
|
+
return False
|
|
241
|
+
return isinstance(message, dict) and message.get("id") == RATE_LIMITS_REQUEST_ID
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_until_reply(server_stdout: IO[str], all_lines: list[str]) -> None:
|
|
245
|
+
for each_line in server_stdout:
|
|
246
|
+
stripped = each_line.strip()
|
|
247
|
+
if stripped:
|
|
248
|
+
all_lines.append(stripped)
|
|
249
|
+
if _is_rate_limits_reply(stripped):
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _collect_lines(
|
|
254
|
+
server_stdout: IO[str], all_lines: list[str], is_done: threading.Event
|
|
255
|
+
) -> None:
|
|
256
|
+
try:
|
|
257
|
+
_read_until_reply(server_stdout, all_lines)
|
|
258
|
+
except (OSError, ValueError):
|
|
259
|
+
pass
|
|
260
|
+
is_done.set()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _stop_server(server: subprocess.Popen[str]) -> None:
|
|
264
|
+
_process_tree_kill.terminate_process_tree(server)
|
|
265
|
+
try:
|
|
266
|
+
server.wait(timeout=PROCESS_WAIT_TIMEOUT_SECONDS)
|
|
267
|
+
except subprocess.TimeoutExpired:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _send_and_collect(server: subprocess.Popen[str], input_text: str) -> list[str]:
|
|
272
|
+
if server.stdin is None or server.stdout is None:
|
|
273
|
+
_stop_server(server)
|
|
274
|
+
raise OSError("codex app-server offered no stdio pipes")
|
|
275
|
+
all_lines: list[str] = []
|
|
276
|
+
is_done = threading.Event()
|
|
277
|
+
reader = threading.Thread(
|
|
278
|
+
target=_collect_lines, args=(server.stdout, all_lines, is_done), daemon=True
|
|
279
|
+
)
|
|
280
|
+
reader.start()
|
|
281
|
+
try:
|
|
282
|
+
server.stdin.write(input_text)
|
|
283
|
+
server.stdin.flush()
|
|
284
|
+
is_done.wait(timeout=APP_SERVER_TIMEOUT_SECONDS)
|
|
285
|
+
finally:
|
|
286
|
+
_stop_server(server)
|
|
287
|
+
reader.join(timeout=READER_JOIN_TIMEOUT_SECONDS)
|
|
288
|
+
return list(all_lines)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def exchange_with_app_server(
|
|
292
|
+
codex_path: Path,
|
|
293
|
+
codex_home: Path,
|
|
294
|
+
all_messages: Sequence[Mapping[str, object]],
|
|
295
|
+
) -> list[str]:
|
|
296
|
+
"""Run ``codex app-server`` under one Codex home and collect the lines it writes.
|
|
297
|
+
|
|
298
|
+
Input stays open until the reply lands, since the server quits unanswered on end of input.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
codex_path: The Codex executable.
|
|
302
|
+
codex_home: The account's Codex home, passed as ``CODEX_HOME``.
|
|
303
|
+
all_messages: JSON-RPC messages to send.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
The non-empty lines, ending with the rate-limit reply when it came.
|
|
307
|
+
"""
|
|
308
|
+
input_text = "".join(json.dumps(each_message) + "\n" for each_message in all_messages)
|
|
309
|
+
with subprocess.Popen(
|
|
310
|
+
[str(codex_path), *ALL_APP_SERVER_ARGUMENTS],
|
|
311
|
+
stdin=subprocess.PIPE,
|
|
312
|
+
stdout=subprocess.PIPE,
|
|
313
|
+
stderr=subprocess.DEVNULL,
|
|
314
|
+
text=True,
|
|
315
|
+
encoding=TEXT_ENCODING,
|
|
316
|
+
env={**os.environ, CODEX_HOME_ENVIRONMENT_VARIABLE: str(codex_home)},
|
|
317
|
+
start_new_session=_process_tree_kill.should_start_new_session(),
|
|
318
|
+
creationflags=_subprocess_window_access.hidden_window_creation_flags(),
|
|
319
|
+
) as server:
|
|
320
|
+
return _send_and_collect(server, input_text)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def read_codex_meters(
|
|
324
|
+
codex_path: Path,
|
|
325
|
+
codex_home: Path,
|
|
326
|
+
exchange: ServerExchange = exchange_with_app_server,
|
|
327
|
+
) -> CodexAccountMeters:
|
|
328
|
+
"""Read one account's windows through the app server under its Codex home.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
codex_path: The Codex executable.
|
|
332
|
+
codex_home: The account's Codex home.
|
|
333
|
+
exchange: Sends messages and returns output lines; tests pass a fake.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The account's windows.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
CodexMeterUnreadError: The server failed, timed out, or sent no usable reply.
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
all_lines = exchange(codex_path, codex_home, request_messages())
|
|
343
|
+
except (OSError, subprocess.SubprocessError, UnicodeDecodeError) as error:
|
|
344
|
+
raise CodexMeterUnreadError(f"codex app-server failed: {error}") from error
|
|
345
|
+
for each_line in all_lines:
|
|
346
|
+
if _is_rate_limits_reply(each_line):
|
|
347
|
+
return parse_rate_limits_reply(each_line)
|
|
348
|
+
raise CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def resolve_codex_path(explicit_path: Path | None) -> Path:
|
|
352
|
+
"""Find the Codex executable.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
explicit_path: A path the caller named, used as given.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
The named path, else ``codex`` on PATH, else a known install path.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
FileNotFoundError: No Codex executable was found.
|
|
362
|
+
"""
|
|
363
|
+
if explicit_path is not None:
|
|
364
|
+
return explicit_path
|
|
365
|
+
on_path = shutil.which(CODEX_BINARY_NAME)
|
|
366
|
+
if on_path is not None:
|
|
367
|
+
return Path(on_path)
|
|
368
|
+
for each_parts in ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS:
|
|
369
|
+
candidate = Path.home().joinpath(*each_parts)
|
|
370
|
+
if candidate.is_file():
|
|
371
|
+
return candidate
|
|
372
|
+
raise FileNotFoundError("no codex executable on PATH or at its install path")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Named constants for the Codex accounts a runner job picks between.
|
|
2
|
+
|
|
3
|
+
Each account signs in under its own Codex home. The picker reads every
|
|
4
|
+
account's rate-limit windows through ``codex app-server`` and names the account
|
|
5
|
+
a job runs on, trying the accounts in one fixed order.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
ALL_CODEX_ACCOUNT_NAMES: tuple[str, ...] = ("codex-1", "codex-2", "codex-3", "codex-4")
|
|
11
|
+
"""Codex accounts in the order jobs try them. The names say nothing about a plan."""
|
|
12
|
+
|
|
13
|
+
CODEX_PROFILES_ROOT_DIRECTORY_NAME: str = ".codex-profiles"
|
|
14
|
+
"""Directory under the user home that holds one Codex home per account."""
|
|
15
|
+
|
|
16
|
+
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE: str = "CODEX_PROFILES_ROOT"
|
|
17
|
+
"""Environment variable that relocates the Codex profiles root."""
|
|
18
|
+
|
|
19
|
+
MAIN_CODEX_HOME_DIRECTORY_NAME: str = ".codex"
|
|
20
|
+
"""Directory under the user home that holds the shared Codex setup."""
|
|
21
|
+
|
|
22
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE: str = "CODEX_HOME"
|
|
23
|
+
"""Environment variable that points Codex at one account's home."""
|
|
24
|
+
|
|
25
|
+
CODEX_AUTH_FILE_NAME: str = "auth.json"
|
|
26
|
+
"""File in a Codex home that holds that account's sign-in."""
|
|
27
|
+
|
|
28
|
+
ALL_SHARED_CODEX_HOME_NAMES: frozenset[str] = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"AGENTS.md",
|
|
31
|
+
"config.toml",
|
|
32
|
+
"hooks",
|
|
33
|
+
"hooks.json",
|
|
34
|
+
"plugins",
|
|
35
|
+
"prompts",
|
|
36
|
+
"rules",
|
|
37
|
+
"skills",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
"""Codex home entries every account shares. Every other entry stays per account."""
|
|
41
|
+
|
|
42
|
+
NORMAL_TIER_MINIMUM_PERCENT_LEFT: float = 10.0
|
|
43
|
+
"""An account runs a job at its normal model only above this percent left."""
|
|
44
|
+
|
|
45
|
+
LUNA_TIER_STOP_PERCENT_LEFT: float = 1.0
|
|
46
|
+
"""A Luna fallback job stops once its account is at or below this percent left."""
|
|
47
|
+
|
|
48
|
+
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT: float = 20.0
|
|
49
|
+
"""An account with a window shorter than a week runs Luna only with this much of it left."""
|
|
50
|
+
|
|
51
|
+
WEEKLY_WINDOW_MINUTES: int = 10080
|
|
52
|
+
"""Length of the weekly window. Any shorter window is a short window, such as 5 hours."""
|
|
53
|
+
|
|
54
|
+
FULL_PERCENT: float = 100.0
|
|
55
|
+
"""Percent scale ceiling, so percent left is this minus percent used."""
|
|
56
|
+
|
|
57
|
+
TIER_NORMAL: str = "normal"
|
|
58
|
+
"""Picker answer: run the job at its normal model on the named account."""
|
|
59
|
+
|
|
60
|
+
TIER_LUNA: str = "luna"
|
|
61
|
+
"""Picker answer: every account is at or under the bar, so Luna runs the job."""
|
|
62
|
+
|
|
63
|
+
TIER_WAIT: str = "wait"
|
|
64
|
+
"""Picker answer: no account has room, so the job waits."""
|
|
65
|
+
|
|
66
|
+
ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
|
|
67
|
+
("AppData", "Local", "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
|
|
68
|
+
)
|
|
69
|
+
"""Install paths under the user home tried when ``codex`` is not on PATH."""
|
|
70
|
+
|
|
71
|
+
CODEX_BINARY_NAME: str = "codex"
|
|
72
|
+
"""Codex command name looked up on PATH."""
|
|
73
|
+
|
|
74
|
+
ALL_APP_SERVER_ARGUMENTS: tuple[str, ...] = ("app-server", "--listen", "stdio://")
|
|
75
|
+
"""Arguments that start Codex as a JSON-RPC server on standard input and output."""
|
|
76
|
+
|
|
77
|
+
APP_SERVER_TIMEOUT_SECONDS: float = 30.0
|
|
78
|
+
"""Longest wait for the rate-limit reply before the read counts as failed."""
|
|
79
|
+
|
|
80
|
+
READER_JOIN_TIMEOUT_SECONDS: float = 2.0
|
|
81
|
+
"""Longest wait for the output reader to finish after the server stops."""
|
|
82
|
+
|
|
83
|
+
PROCESS_WAIT_TIMEOUT_SECONDS: float = 5.0
|
|
84
|
+
"""Longest wait to reap the server after its process tree is stopped."""
|
|
85
|
+
|
|
86
|
+
JSONRPC_VERSION: str = "2.0"
|
|
87
|
+
"""JSON-RPC protocol version on every message."""
|
|
88
|
+
|
|
89
|
+
INITIALIZE_REQUEST_ID: int = 1
|
|
90
|
+
"""Request id of the initialize handshake."""
|
|
91
|
+
|
|
92
|
+
RATE_LIMITS_REQUEST_ID: int = 2
|
|
93
|
+
"""Request id of the rate-limit read."""
|
|
94
|
+
|
|
95
|
+
METHOD_INITIALIZE: str = "initialize"
|
|
96
|
+
"""JSON-RPC method that opens the session."""
|
|
97
|
+
|
|
98
|
+
METHOD_INITIALIZED: str = "initialized"
|
|
99
|
+
"""JSON-RPC notification that confirms the session opened."""
|
|
100
|
+
|
|
101
|
+
METHOD_RATE_LIMITS_READ: str = "account/rateLimits/read"
|
|
102
|
+
"""JSON-RPC method that returns the account's rate-limit windows."""
|
|
103
|
+
|
|
104
|
+
CLIENT_NAME: str = "codex-account-choice"
|
|
105
|
+
"""Client name the handshake reports."""
|
|
106
|
+
|
|
107
|
+
CLIENT_VERSION: str = "1.0.0"
|
|
108
|
+
"""Client version the handshake reports."""
|
|
109
|
+
|
|
110
|
+
ALL_WINDOW_KEYS: tuple[str, ...] = ("primary", "secondary")
|
|
111
|
+
"""Keys under ``rateLimits`` that each hold one usage window."""
|
|
112
|
+
|
|
113
|
+
EXIT_CODE_ROOM: int = 0
|
|
114
|
+
"""Check answer: the account is above the floor."""
|
|
115
|
+
|
|
116
|
+
EXIT_CODE_NO_ROOM: int = 3
|
|
117
|
+
"""Check answer: the account is at or below the floor, or its meter is unread."""
|
|
118
|
+
|
|
119
|
+
REASON_NORMAL_TEMPLATE: str = "{account} has {percent_left:.0f}% left"
|
|
120
|
+
"""Reason when an account takes the job at its normal model."""
|
|
121
|
+
|
|
122
|
+
REASON_LUNA_TEMPLATE: str = (
|
|
123
|
+
"every account is at or under {bar:.0f}% left; {account} has"
|
|
124
|
+
" {percent_left:.0f}% and runs Luna until {stop:.0f}%"
|
|
125
|
+
)
|
|
126
|
+
"""Reason when the job falls back to Luna."""
|
|
127
|
+
|
|
128
|
+
REASON_WAIT_TEMPLATE: str = "no account has room; {account} resets first, at {reset}"
|
|
129
|
+
"""Reason when every account is out of room."""
|
|
130
|
+
|
|
131
|
+
REASON_WAIT_UNREAD: str = "no account meter could be read"
|
|
132
|
+
"""Reason when no account's meter reads at all."""
|
|
133
|
+
|
|
134
|
+
UNREAD_NOT_SIGNED_IN: str = "not signed in"
|
|
135
|
+
"""Unread reason when the account's Codex home holds no sign-in."""
|
|
136
|
+
|
|
137
|
+
UNKNOWN_RESET_TEXT: str = "an unknown time"
|
|
138
|
+
"""Reset text when no blocking window carries a reset time."""
|
|
139
|
+
|
|
140
|
+
TEXT_ENCODING: str = "utf-8"
|
|
141
|
+
"""Encoding for the server pipes and the JSON reports."""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Digests of the private
|
|
1
|
+
"""Digests of the private names this public repository never names.
|
|
2
2
|
|
|
3
|
-
The names
|
|
3
|
+
The names cover private organizations, the owner's private repository, the
|
|
4
|
+
owner's personal handle, and a private client. They stay out of the tree. Each entry holds the SHA-256 digest of one
|
|
4
5
|
name after normalization, which keeps only ASCII letters and digits in lower
|
|
5
6
|
case::
|
|
6
7
|
|
|
@@ -31,6 +32,18 @@ ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
|
|
|
31
32
|
length=10,
|
|
32
33
|
sha256="c2a70b3186691a74dece4ca824bec178bfaec83be119be28faedd14aa392233f",
|
|
33
34
|
),
|
|
35
|
+
PrivateTermDigest(
|
|
36
|
+
length=16,
|
|
37
|
+
sha256="182cb82e3e973d041b8527659064153f8c4af79503bd198508d3655d9a932fdf",
|
|
38
|
+
),
|
|
39
|
+
PrivateTermDigest(
|
|
40
|
+
length=7,
|
|
41
|
+
sha256="d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
|
|
42
|
+
),
|
|
43
|
+
PrivateTermDigest(
|
|
44
|
+
length=7,
|
|
45
|
+
sha256="968e2d5b08687bf42997461cbdef6c844eabbf04f440cee888c95b864c2a4bcc",
|
|
46
|
+
),
|
|
34
47
|
}
|
|
35
48
|
)
|
|
36
49
|
PRIVATE_TERM_TEXT_ENCODING = "utf-8"
|
|
@@ -57,4 +70,6 @@ SHORT_SHA_LENGTH = 12
|
|
|
57
70
|
COMMIT_FIELD_SEPARATOR = "\x1f"
|
|
58
71
|
COMMIT_RECORD_SEPARATOR = "\x1e"
|
|
59
72
|
COMMIT_FIELD_SPLIT_LIMIT = 2
|
|
73
|
+
GITHUB_NOREPLY_ADDRESS_PATTERN = r"<[^<>]*@users\.noreply\.github\.com>"
|
|
74
|
+
MASKED_ADDRESS = "<>"
|
|
60
75
|
COMMIT_LOG_FORMAT = "--format=%H%x1f%an <%ae> %cn <%ce>%x1f%B%x1e"
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import argparse
|
|
14
14
|
import json
|
|
15
|
+
import re
|
|
15
16
|
import subprocess
|
|
16
17
|
import sys
|
|
17
18
|
from collections.abc import Sequence
|
|
@@ -28,6 +29,8 @@ from dev_env_scripts_constants.private_term_constants import (
|
|
|
28
29
|
COMMIT_MESSAGE_PART,
|
|
29
30
|
COMMIT_RECORD_SEPARATOR,
|
|
30
31
|
FINDING_LINE_TEMPLATE,
|
|
32
|
+
GITHUB_NOREPLY_ADDRESS_PATTERN,
|
|
33
|
+
MASKED_ADDRESS,
|
|
31
34
|
PRIVATE_TERM_MESSAGE_TEMPLATE,
|
|
32
35
|
PRIVATE_TERM_TEXT_ENCODING,
|
|
33
36
|
SHORT_SHA_LENGTH,
|
|
@@ -76,7 +79,7 @@ def _commit_findings(record: str) -> list[str]:
|
|
|
76
79
|
return [
|
|
77
80
|
*_findings_for(
|
|
78
81
|
COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_IDENTITY_PART),
|
|
79
|
-
identity,
|
|
82
|
+
re.sub(GITHUB_NOREPLY_ADDRESS_PATTERN, MASKED_ADDRESS, identity, flags=re.IGNORECASE),
|
|
80
83
|
),
|
|
81
84
|
*_findings_for(
|
|
82
85
|
COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_MESSAGE_PART),
|
|
@@ -94,7 +97,8 @@ def scan_commits(repository_root: Path, commit_range: str) -> list[str]:
|
|
|
94
97
|
|
|
95
98
|
Returns:
|
|
96
99
|
One finding per commit identity or message line that names a private
|
|
97
|
-
organization.
|
|
100
|
+
organization. An identity address ending in users.noreply.github.com
|
|
101
|
+
is left out of the scan, since GitHub builds it from the account handle.
|
|
98
102
|
"""
|
|
99
103
|
log_text = subprocess.run(
|
|
100
104
|
["git", "-C", str(repository_root), "log", "--reverse", COMMIT_LOG_FORMAT, commit_range],
|
|
@@ -13,6 +13,7 @@ CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
|
|
|
13
13
|
CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
|
|
14
14
|
CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
|
|
15
15
|
CHECK_ID_TRACKED_PRIVATE_TERMS = "tracked-private-terms"
|
|
16
|
+
PRIVATE_TERMS_SKIPPED_FILE_NAME = "CHANGELOG.md"
|
|
16
17
|
|
|
17
18
|
ALL_CHECK_IDS = (
|
|
18
19
|
CHECK_ID_CLAUDE_MD_ORPHANS,
|
|
@@ -48,11 +48,18 @@ def collect_tracked_private_term_findings(
|
|
|
48
48
|
Findings that carry the path and line number, never the name. A
|
|
49
49
|
repository whose github.com origin owner is itself a private
|
|
50
50
|
organization returns none, since that organization may name itself.
|
|
51
|
+
A file named CHANGELOG.md is skipped, since release automation writes
|
|
52
|
+
it from commit subjects and history keeps it.
|
|
51
53
|
"""
|
|
52
54
|
if _is_owned_by_private_organization(repository_root):
|
|
53
55
|
return []
|
|
54
56
|
all_findings: list[RepositoryFinding] = []
|
|
55
57
|
for each_relative_path in all_tracked_paths:
|
|
58
|
+
if (
|
|
59
|
+
Path(each_relative_path).name
|
|
60
|
+
== repository_constants.PRIVATE_TERMS_SKIPPED_FILE_NAME
|
|
61
|
+
):
|
|
62
|
+
continue
|
|
56
63
|
if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
|
|
57
64
|
continue
|
|
58
65
|
posix_relative_path = each_relative_path.replace(
|
|
@@ -76,6 +76,18 @@ class TestSyncProfile:
|
|
|
76
76
|
profile_home / "settings.json", main_home / "settings.json"
|
|
77
77
|
)
|
|
78
78
|
|
|
79
|
+
def should_link_only_what_the_callers_rule_shares(self, tmp_path: Path) -> None:
|
|
80
|
+
main_home = build_main_home(tmp_path)
|
|
81
|
+
profile_home = tmp_path / "codex-1"
|
|
82
|
+
report = profile.sync_profile(
|
|
83
|
+
main_home=main_home,
|
|
84
|
+
profile_home=profile_home,
|
|
85
|
+
now=NOW,
|
|
86
|
+
is_local=lambda entry_name: entry_name != "rules",
|
|
87
|
+
)
|
|
88
|
+
assert report.all_linked == ("rules",)
|
|
89
|
+
assert not (profile_home / "skills").exists()
|
|
90
|
+
|
|
79
91
|
def should_keep_the_accounts_own_sign_in_state_and_history_apart(
|
|
80
92
|
self, tmp_path: Path
|
|
81
93
|
) -> None:
|