claude-dev-env 2.2.0 → 2.3.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/CLAUDE.md +1 -1
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/code-verifier.md +1 -1
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/config/verified_commit_constants.py +11 -0
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/blocking/test_verification_verdict_store.py +93 -0
- package/hooks/blocking/verification_verdict_store.py +91 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/check.ps1 +32 -6
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/team-advisor/SKILL.md +3 -2
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -71,7 +71,7 @@ Capture `number`, `head.sha` (= `current_head`), owner/repo, branch.
|
|
|
71
71
|
|
|
72
72
|
The **PR worktree** is the local working tree of the PR's repo on its head
|
|
73
73
|
branch. Every local operation this tick runs there: the CODE_REVIEW static sweep
|
|
74
|
-
and `/code-review
|
|
74
|
+
and `/code-review xhigh --fix`, every `clean-coder` fix spawn, and every commit and
|
|
75
75
|
push. `/code-review` and `git` both act on the repo of the current working
|
|
76
76
|
directory, so the working directory must be the PR worktree before any local
|
|
77
77
|
work begins. Re-resolve it every tick — a rebase or a fresh HEAD can move the
|
|
@@ -180,7 +180,7 @@ CODE_REVIEW.
|
|
|
180
180
|
### `phase == CODE_REVIEW`
|
|
181
181
|
|
|
182
182
|
The entry phase of every convergence tick, re-entered after any fix push. It runs
|
|
183
|
-
a deterministic static sweep, then the built-in `/code-review
|
|
183
|
+
a deterministic static sweep, then the built-in `/code-review xhigh --fix` on the
|
|
184
184
|
full `origin/main...HEAD` diff at effort high on model opus. `/code-review`
|
|
185
185
|
produces no GitHub review artifact, so there are no code-review threads to
|
|
186
186
|
resolve.
|
|
@@ -196,7 +196,7 @@ a. **Static sweep — runs first, before `/code-review`.** Run the deterministic
|
|
|
196
196
|
`phase = CODE_REVIEW`, and re-run the sweep. When the sweep is clean, run
|
|
197
197
|
the host-aware review below.
|
|
198
198
|
|
|
199
|
-
b. Run the built-in `/code-review
|
|
199
|
+
b. Run the built-in `/code-review xhigh --fix` with OPUS on the FULL `origin/main...HEAD`
|
|
200
200
|
diff — every file the PR touches — via the
|
|
201
201
|
[local diff review](https://code.claude.com/docs/en/code-review#review-a-diff-locally).
|
|
202
202
|
The review always runs at effort high on model opus. It reviews the diff and
|
|
@@ -231,14 +231,14 @@ b. Run the built-in `/code-review high --fix` on the FULL `origin/main...HEAD`
|
|
|
231
231
|
Match the first mode whose predicate holds:
|
|
232
232
|
|
|
233
233
|
- **`mode == "in_session"`** (Claude host and session model is opus): run
|
|
234
|
-
`/code-review
|
|
234
|
+
`/code-review xhigh --fix` with OPUS in this session with no path arguments so it
|
|
235
235
|
audits the whole branch diff against `origin/main`. After it returns, a
|
|
236
236
|
non-empty `git status --porcelain` means fixes applied (`dirty_tree`
|
|
237
237
|
equivalent). Treat a failed in-session slash command the same as a failed
|
|
238
238
|
review: do not set `code_review_clean_at`.
|
|
239
239
|
- **`mode == "chain"`** (any other host, or a Claude session on any model
|
|
240
240
|
other than opus): the helper already ran the headless review
|
|
241
|
-
(`claude -p "/code-review
|
|
241
|
+
(`claude -p "/code-review xhigh --fix" --model opus` through the chain
|
|
242
242
|
runner) with cwd set to the PR worktree. Read `returncode`,
|
|
243
243
|
`served_command`, and `dirty_tree` from the JSON. A successful serve is
|
|
244
244
|
`returncode == 0` with a non-null `served_command`. `dirty_tree` true
|
|
@@ -91,7 +91,7 @@ round as converged. This rule holds every tick, every loop, every PR.
|
|
|
91
91
|
- [ ] **Static sweep fails** → apply shared fix protocol → push → reset markers
|
|
92
92
|
→ stay CODE_REVIEW → Step 5
|
|
93
93
|
- [ ] **`mode == in_session`** (Claude host, session model opus) → run
|
|
94
|
-
`/code-review
|
|
94
|
+
`/code-review xhigh --fix` in-session (no path args)
|
|
95
95
|
- [ ] **`mode == chain`** (any other host or non-opus session) → helper
|
|
96
96
|
already ran the headless review; read `returncode`,
|
|
97
97
|
`served_command`, and `dirty_tree` from JSON
|
|
@@ -32,7 +32,7 @@ NEVER_PUSHES_PHRASE = "never pushes"
|
|
|
32
32
|
EMPTY_STDIN_PHRASE = "empty"
|
|
33
33
|
CWD_FLAG = "--cwd"
|
|
34
34
|
OPUS_MODEL = "opus"
|
|
35
|
-
HIGH_EFFORT_SLASH = "/code-review
|
|
35
|
+
HIGH_EFFORT_SLASH = "/code-review xhigh --fix"
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _read_markdown(markdown_path: Path) -> str:
|
|
@@ -30,8 +30,9 @@ the only consumer (skip the "who you are and your assignment" opener in each
|
|
|
30
30
|
consult; a single-consumer session doesn't need it).
|
|
31
31
|
|
|
32
32
|
**Third-party host:** bind a max-tier Claude advisor through the shared CLI Claude-chain
|
|
33
|
-
in the protocol (Fable max, then Opus max; `claude_chain_runner.py`
|
|
34
|
-
`~/.claude/claude-chain.json`
|
|
33
|
+
in the protocol (Fable max, then Opus max; `claude_chain_runner.py` ranks
|
|
34
|
+
`~/.claude/claude-chain.json` accounts by weekly remaining via `claude_chain_usage`
|
|
35
|
+
and fails over on usage limits). Consult via
|
|
35
36
|
`--resume <session_id>` on that bind. This session is the sole consumer of that
|
|
36
37
|
CLI advisor; skip the multi-consumer opener. When the chain cannot bind or
|
|
37
38
|
reply, fail closed and report to the user — do **not** answer ENDORSE /
|
|
@@ -41,10 +41,10 @@ On exit 2 the script prints `{"error": ...}`. Ask the user for a manual reset ti
|
|
|
41
41
|
|
|
42
42
|
`scripts/resolve_usage_window.py` is the source of truth for live probe behavior. Endpoint URL, header names/values, credential path and token keys, response bucket keys, stage sizing, and the weekly warn threshold all live in `scripts/usage_pause_constants/resolve_usage_window_constants.py` — read those modules for the current values; do not restate them here.
|
|
43
43
|
|
|
44
|
-
In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources
|
|
44
|
+
In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources depend on the host:
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
- **Desktop host** (the `CLAUDE_CODE_ENTRYPOINT` variable is `claude-desktop`): the resolver does not read the CLI credential file, which belongs to a different authentication session than the one the desktop app counts usage against. It uses the session ingress token when one is set, and otherwise takes the manual-override path.
|
|
47
|
+
- **Every other host**: the resolver reads the Claude Code CLI's stored OAuth access token first (honored only while unexpired), then the session ingress bearer token file named by `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (cloud sessions) when the credential token is unavailable.
|
|
48
48
|
|
|
49
49
|
Fallbacks, in order: both token sources unavailable (expired/unreadable credential and no ingress file), a failed request, or a response with no readable session-window reset time all end in exit 2 — the manual-override ask above. The manual path works with no probe at all, so the skill functions even when both token sources are unavailable.
|
|
50
50
|
|
|
@@ -106,8 +106,9 @@ Fill each `<slot>` at schedule time: `<remaining_stage_durations>` is the tail o
|
|
|
106
106
|
| `SKILL.md` | This flow: resolve, weekly guard, stage chain, templates |
|
|
107
107
|
| `scripts/resolve_usage_window.py` | The window resolver and stage planner CLI |
|
|
108
108
|
| `scripts/test_resolve_usage_window.py` | Behavioral tests for parsing, staging, token reading, extraction, CLI |
|
|
109
|
-
| `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, stage sizing, thresholds, result keys |
|
|
109
|
+
| `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, host detection, stage sizing, thresholds, result keys |
|
|
110
110
|
|
|
111
111
|
## Gotchas
|
|
112
112
|
|
|
113
113
|
- The stored access token lives about 8 hours and the CLI rewrites it on its own schedule, so a mid-afternoon probe can find it expired even while the CLI itself still works. That is the designed exit-2 path: give a manual time.
|
|
114
|
+
- On the desktop host the resolver never reads the CLI credential file, because that credential is a different authentication session than the one the desktop app counts usage against. With no session ingress token set, the desktop host takes the manual path: read the reset time from the interactive `/usage` panel and pass it, for example `/usage-pause 10:20pm`.
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
{"source": "override", "reset_at": "2026-07-08T10:14:00-07:00",
|
|
8
8
|
"seconds_until_reset": 4440, "stages_seconds": [3480, 960, 120], ...}
|
|
9
9
|
|
|
10
|
-
With no ``--override``, the script resolves a bearer token
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
With no ``--override``, the script resolves a bearer token. On the desktop
|
|
11
|
+
host it uses only the session ingress token. On every other host it reads the
|
|
12
|
+
Claude Code OAuth access token from the CLI credential file, then the session
|
|
13
|
+
ingress token file when that credential is unavailable. It asks the OAuth
|
|
14
|
+
usage endpoint for the ``five_hour`` and ``seven_day`` windows. Exit code 2
|
|
15
|
+
means the probe cannot resolve; the caller then asks the user for a manual
|
|
16
|
+
reset time.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -41,7 +43,9 @@ from usage_pause_constants.resolve_usage_window_constants import (
|
|
|
41
43
|
CREDENTIALS_ACCESS_TOKEN_KEY,
|
|
42
44
|
CREDENTIALS_EXPIRES_AT_KEY,
|
|
43
45
|
CREDENTIALS_OAUTH_SECTION_KEY,
|
|
46
|
+
DESKTOP_ENTRYPOINT_VALUE,
|
|
44
47
|
DURATION_PATTERN,
|
|
48
|
+
ENTRYPOINT_ENV_VAR,
|
|
45
49
|
EPOCH_MILLISECONDS_THRESHOLD,
|
|
46
50
|
EXIT_CODE_PROBE_UNAVAILABLE,
|
|
47
51
|
EXIT_CODE_RESOLVED,
|
|
@@ -267,14 +271,36 @@ def read_session_ingress_token() -> str | None:
|
|
|
267
271
|
return token_text
|
|
268
272
|
|
|
269
273
|
|
|
274
|
+
def running_on_desktop_host() -> bool:
|
|
275
|
+
"""Tell whether this process runs under the Claude desktop app.
|
|
276
|
+
|
|
277
|
+
::
|
|
278
|
+
|
|
279
|
+
entrypoint is the desktop marker -> True
|
|
280
|
+
entrypoint is the CLI or unset -> False
|
|
281
|
+
|
|
282
|
+
The desktop app meters its session under a different auth session than
|
|
283
|
+
the CLI credential file, so a true answer tells the resolver to leave
|
|
284
|
+
that credential file alone.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True when the entrypoint environment variable names the desktop app.
|
|
288
|
+
"""
|
|
289
|
+
return os.environ.get(ENTRYPOINT_ENV_VAR) == DESKTOP_ENTRYPOINT_VALUE
|
|
290
|
+
|
|
291
|
+
|
|
270
292
|
def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
271
293
|
"""Choose the usage-endpoint bearer token from its available sources.
|
|
272
294
|
|
|
273
295
|
::
|
|
274
296
|
|
|
275
|
-
credential
|
|
276
|
-
credential token unavailable
|
|
277
|
-
|
|
297
|
+
CLI host, credential token valid -> the credential token
|
|
298
|
+
CLI host, credential token unavailable -> the session ingress token
|
|
299
|
+
desktop host -> the session ingress token
|
|
300
|
+
no source available -> None
|
|
301
|
+
|
|
302
|
+
On the desktop host the CLI credential file belongs to a different auth
|
|
303
|
+
session, so it is skipped and only the session ingress token is honored.
|
|
278
304
|
|
|
279
305
|
Args:
|
|
280
306
|
credentials_path: The CLI credential file holding the OAuth section.
|
|
@@ -283,6 +309,8 @@ def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
|
283
309
|
Returns:
|
|
284
310
|
The bearer token for the usage endpoint, or None when no source has one.
|
|
285
311
|
"""
|
|
312
|
+
if running_on_desktop_host():
|
|
313
|
+
return read_session_ingress_token()
|
|
286
314
|
credential_token = read_oauth_access_token(credentials_path, now)
|
|
287
315
|
return credential_token or read_session_ingress_token()
|
|
288
316
|
|
|
@@ -447,6 +475,37 @@ def _describe_ingress_token_source() -> str:
|
|
|
447
475
|
return f"the session ingress token file ({SESSION_INGRESS_TOKEN_FILE_ENV_VAR} set but empty)"
|
|
448
476
|
|
|
449
477
|
|
|
478
|
+
def _no_token_error_message(credentials_path: Path) -> str:
|
|
479
|
+
"""Build the no-usable-token exit-2 error, host-aware for the desktop app.
|
|
480
|
+
|
|
481
|
+
::
|
|
482
|
+
|
|
483
|
+
desktop host -> names the different-session skip and the /usage remedy
|
|
484
|
+
other host -> names the credential path and the ingress source tried
|
|
485
|
+
|
|
486
|
+
On the desktop host the CLI credential file is never read, so the message
|
|
487
|
+
points the user at the interactive /usage panel for a manual reset time.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
credentials_path: The CLI credential file the non-desktop message names.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
The error text for the no-usable-token exit-2 path.
|
|
494
|
+
"""
|
|
495
|
+
if running_on_desktop_host():
|
|
496
|
+
return (
|
|
497
|
+
"on the desktop host the CLI credential file belongs to a different "
|
|
498
|
+
f"auth session and is not read, and {_describe_ingress_token_source()} "
|
|
499
|
+
"has no token; read the reset time from the interactive /usage panel "
|
|
500
|
+
"and give it manually, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
501
|
+
)
|
|
502
|
+
return (
|
|
503
|
+
"no usable bearer token from the OAuth credential file at "
|
|
504
|
+
f"{credentials_path} or {_describe_ingress_token_source()}; give a "
|
|
505
|
+
"manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
450
509
|
def _parse_arguments() -> argparse.Namespace:
|
|
451
510
|
parser = argparse.ArgumentParser(
|
|
452
511
|
description="Resolve the 5-hour usage window reset and plan the pause stage chain.",
|
|
@@ -503,11 +562,7 @@ def main() -> int:
|
|
|
503
562
|
)
|
|
504
563
|
access_token = resolve_access_token(credentials_path, now)
|
|
505
564
|
if access_token is None:
|
|
506
|
-
return _emit_error(
|
|
507
|
-
"no usable bearer token from the OAuth credential file at "
|
|
508
|
-
f"{credentials_path} or {_describe_ingress_token_source()}; give a "
|
|
509
|
-
"manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
510
|
-
)
|
|
565
|
+
return _emit_error(_no_token_error_message(credentials_path))
|
|
511
566
|
try:
|
|
512
567
|
usage_payload = _fetch_usage_payload(access_token)
|
|
513
568
|
except (
|
|
@@ -18,7 +18,9 @@ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
|
18
18
|
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
19
19
|
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
20
20
|
|
|
21
|
-
from usage_pause_constants.resolve_usage_window_constants import (
|
|
21
|
+
from usage_pause_constants.resolve_usage_window_constants import ( # noqa: E402
|
|
22
|
+
DESKTOP_ENTRYPOINT_VALUE,
|
|
23
|
+
ENTRYPOINT_ENV_VAR,
|
|
22
24
|
SESSION_INGRESS_TOKEN_FILE_ENV_VAR as INGRESS_TOKEN_FILE_ENV_VAR,
|
|
23
25
|
)
|
|
24
26
|
|
|
@@ -188,8 +190,7 @@ class TestReadOauthAccessToken:
|
|
|
188
190
|
credentials_path.write_text("{not valid json", encoding="utf-8")
|
|
189
191
|
with caplog.at_level(logging.WARNING):
|
|
190
192
|
assert (
|
|
191
|
-
resolver.read_oauth_access_token(credentials_path, local_now())
|
|
192
|
-
is None
|
|
193
|
+
resolver.read_oauth_access_token(credentials_path, local_now()) is None
|
|
193
194
|
)
|
|
194
195
|
assert any("unreadable" in each_message for each_message in caplog.messages)
|
|
195
196
|
|
|
@@ -240,7 +241,43 @@ class TestReadSessionIngressToken:
|
|
|
240
241
|
assert INGRESS_TOKEN_FILE_ENV_VAR == "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
|
|
241
242
|
|
|
242
243
|
|
|
244
|
+
class TestRunningOnDesktopHost:
|
|
245
|
+
def should_be_true_when_entrypoint_names_the_desktop_app(
|
|
246
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
247
|
+
) -> None:
|
|
248
|
+
resolver = load_resolver_module()
|
|
249
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
|
|
250
|
+
assert resolver.running_on_desktop_host() is True
|
|
251
|
+
|
|
252
|
+
def should_be_false_when_entrypoint_names_a_non_desktop_host(
|
|
253
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
254
|
+
) -> None:
|
|
255
|
+
resolver = load_resolver_module()
|
|
256
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, "cli")
|
|
257
|
+
assert resolver.running_on_desktop_host() is False
|
|
258
|
+
|
|
259
|
+
def should_be_false_when_entrypoint_is_unset(
|
|
260
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
261
|
+
) -> None:
|
|
262
|
+
resolver = load_resolver_module()
|
|
263
|
+
monkeypatch.delenv(ENTRYPOINT_ENV_VAR, raising=False)
|
|
264
|
+
assert resolver.running_on_desktop_host() is False
|
|
265
|
+
|
|
266
|
+
def should_pin_the_desktop_entrypoint_contract_to_the_literal_marker(
|
|
267
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
268
|
+
) -> None:
|
|
269
|
+
resolver = load_resolver_module()
|
|
270
|
+
monkeypatch.setenv("CLAUDE_CODE_ENTRYPOINT", "claude-desktop")
|
|
271
|
+
assert resolver.running_on_desktop_host() is True
|
|
272
|
+
assert ENTRYPOINT_ENV_VAR == "CLAUDE_CODE_ENTRYPOINT"
|
|
273
|
+
assert DESKTOP_ENTRYPOINT_VALUE == "claude-desktop"
|
|
274
|
+
|
|
275
|
+
|
|
243
276
|
class TestResolveAccessToken:
|
|
277
|
+
@pytest.fixture(autouse=True)
|
|
278
|
+
def force_non_desktop_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
279
|
+
monkeypatch.delenv(ENTRYPOINT_ENV_VAR, raising=False)
|
|
280
|
+
|
|
244
281
|
def should_use_ingress_token_when_credential_file_missing(
|
|
245
282
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
246
283
|
) -> None:
|
|
@@ -295,6 +332,57 @@ class TestResolveAccessToken:
|
|
|
295
332
|
assert resolver.resolve_access_token(missing_credentials, local_now()) is None
|
|
296
333
|
|
|
297
334
|
|
|
335
|
+
class TestResolveAccessTokenHostAware:
|
|
336
|
+
def should_ignore_a_valid_cli_credential_token_on_the_desktop_host(
|
|
337
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
338
|
+
) -> None:
|
|
339
|
+
resolver = load_resolver_module()
|
|
340
|
+
now = local_now()
|
|
341
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
342
|
+
future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
|
|
343
|
+
write_credentials(
|
|
344
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
345
|
+
)
|
|
346
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
|
|
347
|
+
monkeypatch.delenv(INGRESS_TOKEN_FILE_ENV_VAR, raising=False)
|
|
348
|
+
chosen_token = resolver.resolve_access_token(credentials_path, now)
|
|
349
|
+
assert chosen_token != "cli-credential-token"
|
|
350
|
+
assert chosen_token is None
|
|
351
|
+
|
|
352
|
+
def should_use_the_ingress_token_over_the_cli_credential_on_the_desktop_host(
|
|
353
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
354
|
+
) -> None:
|
|
355
|
+
resolver = load_resolver_module()
|
|
356
|
+
now = local_now()
|
|
357
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
358
|
+
future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
|
|
359
|
+
write_credentials(
|
|
360
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
361
|
+
)
|
|
362
|
+
token_file = tmp_path / "ingress-token"
|
|
363
|
+
token_file.write_text("ingress-token-value", encoding="utf-8")
|
|
364
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
|
|
365
|
+
monkeypatch.setenv(INGRESS_TOKEN_FILE_ENV_VAR, str(token_file))
|
|
366
|
+
chosen_token = resolver.resolve_access_token(credentials_path, now)
|
|
367
|
+
assert chosen_token == "ingress-token-value"
|
|
368
|
+
assert chosen_token != "cli-credential-token"
|
|
369
|
+
|
|
370
|
+
def should_return_the_cli_credential_token_on_a_non_desktop_host(
|
|
371
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
372
|
+
) -> None:
|
|
373
|
+
resolver = load_resolver_module()
|
|
374
|
+
now = local_now()
|
|
375
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
376
|
+
future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
|
|
377
|
+
write_credentials(
|
|
378
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
379
|
+
)
|
|
380
|
+
monkeypatch.setenv(ENTRYPOINT_ENV_VAR, "cli")
|
|
381
|
+
monkeypatch.delenv(INGRESS_TOKEN_FILE_ENV_VAR, raising=False)
|
|
382
|
+
chosen_token = resolver.resolve_access_token(credentials_path, now)
|
|
383
|
+
assert chosen_token == "cli-credential-token"
|
|
384
|
+
|
|
385
|
+
|
|
298
386
|
class TestExtractUsageWindows:
|
|
299
387
|
def should_extract_session_and_weekly_buckets(self) -> None:
|
|
300
388
|
resolver = load_resolver_module()
|
|
@@ -361,13 +449,20 @@ class TestBuildPausePlan:
|
|
|
361
449
|
|
|
362
450
|
class TestCommandLine:
|
|
363
451
|
def run_resolver(
|
|
364
|
-
self,
|
|
452
|
+
self,
|
|
453
|
+
*arguments: str,
|
|
454
|
+
ingress_token_file: str | None = None,
|
|
455
|
+
entrypoint: str | None = None,
|
|
365
456
|
) -> subprocess.CompletedProcess[str]:
|
|
366
457
|
child_environment = dict(os.environ)
|
|
367
458
|
if ingress_token_file is None:
|
|
368
459
|
child_environment.pop(INGRESS_TOKEN_FILE_ENV_VAR, None)
|
|
369
460
|
else:
|
|
370
461
|
child_environment[INGRESS_TOKEN_FILE_ENV_VAR] = ingress_token_file
|
|
462
|
+
if entrypoint is None:
|
|
463
|
+
child_environment.pop(ENTRYPOINT_ENV_VAR, None)
|
|
464
|
+
else:
|
|
465
|
+
child_environment[ENTRYPOINT_ENV_VAR] = entrypoint
|
|
371
466
|
return subprocess.run(
|
|
372
467
|
[sys.executable, str(RESOLVER_PATH), *arguments],
|
|
373
468
|
capture_output=True,
|
|
@@ -403,9 +498,7 @@ class TestCommandLine:
|
|
|
403
498
|
assert str(missing_path) in resolved_payload["error"]
|
|
404
499
|
assert f"{INGRESS_TOKEN_FILE_ENV_VAR} unset" in resolved_payload["error"]
|
|
405
500
|
|
|
406
|
-
def should_name_the_ingress_token_path_in_the_error(
|
|
407
|
-
self, tmp_path: Path
|
|
408
|
-
) -> None:
|
|
501
|
+
def should_name_the_ingress_token_path_in_the_error(self, tmp_path: Path) -> None:
|
|
409
502
|
missing_credentials = tmp_path / "absent.json"
|
|
410
503
|
stale_ingress = tmp_path / "stale-ingress-token"
|
|
411
504
|
completed = self.run_resolver(
|
|
@@ -429,10 +522,29 @@ class TestCommandLine:
|
|
|
429
522
|
assert completed.returncode == 2
|
|
430
523
|
resolved_payload = json.loads(completed.stdout)
|
|
431
524
|
assert (
|
|
432
|
-
f"{INGRESS_TOKEN_FILE_ENV_VAR} set but empty"
|
|
433
|
-
in resolved_payload["error"]
|
|
525
|
+
f"{INGRESS_TOKEN_FILE_ENV_VAR} set but empty" in resolved_payload["error"]
|
|
434
526
|
)
|
|
435
527
|
|
|
528
|
+
def should_exit_two_with_a_host_aware_error_on_the_desktop_host(
|
|
529
|
+
self, tmp_path: Path
|
|
530
|
+
) -> None:
|
|
531
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
532
|
+
future_milliseconds = int((local_now() + timedelta(hours=1)).timestamp() * 1000)
|
|
533
|
+
write_credentials(
|
|
534
|
+
credentials_path, future_milliseconds, access_token="cli-credential-token"
|
|
535
|
+
)
|
|
536
|
+
completed = self.run_resolver(
|
|
537
|
+
"--credentials-path",
|
|
538
|
+
str(credentials_path),
|
|
539
|
+
entrypoint="claude-desktop",
|
|
540
|
+
)
|
|
541
|
+
assert completed.returncode == 2
|
|
542
|
+
assert "cli-credential-token" not in completed.stdout
|
|
543
|
+
error_text = json.loads(completed.stdout)["error"]
|
|
544
|
+
assert "different auth session" in error_text
|
|
545
|
+
assert "/usage" in error_text
|
|
546
|
+
assert "/usage-pause" in error_text
|
|
547
|
+
|
|
436
548
|
def should_reject_invalid_override_with_error_payload(self) -> None:
|
|
437
549
|
completed = self.run_resolver("--override", "soon")
|
|
438
550
|
assert completed.returncode == 2
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Constants for the usage-window resolver.
|
|
2
2
|
|
|
3
3
|
Groups: the OAuth usage-endpoint probe, the CLI credential file keys, the
|
|
4
|
-
session ingress token file environment variable, the
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
session ingress token file environment variable, the host entrypoint
|
|
5
|
+
detection, the usage-response field keys, the override parse patterns, the
|
|
6
|
+
wakeup stage sizing, the weekly warn threshold, the result JSON keys, the
|
|
7
|
+
source labels, and the exit codes.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -24,6 +25,9 @@ CREDENTIALS_EXPIRES_AT_KEY = "expiresAt"
|
|
|
24
25
|
SESSION_INGRESS_TOKEN_FILE_ENV_VAR = "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
|
|
25
26
|
MILLISECONDS_PER_SECOND = 1000
|
|
26
27
|
|
|
28
|
+
ENTRYPOINT_ENV_VAR = "CLAUDE_CODE_ENTRYPOINT"
|
|
29
|
+
DESKTOP_ENTRYPOINT_VALUE = "claude-desktop"
|
|
30
|
+
|
|
27
31
|
FIVE_HOUR_BUCKET_KEY = "five_hour"
|
|
28
32
|
SEVEN_DAY_BUCKET_KEY = "seven_day"
|
|
29
33
|
UTILIZATION_KEY = "utilization"
|