claude-dev-env 8.48.6 → 8.49.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/SKILL.md +3 -0
- package/docs/account-broker.md +1 -1
- package/hooks/blocking/gh_global_account_switch_gate.py +116 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +8 -3
- package/hooks/blocking/test_gh_global_account_switch_gate.py +100 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/gh_global_account_switch_gate_constants.py +33 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/routing/test_subagent_model_pin_hook.py +1 -0
- package/package.json +1 -1
- package/scripts/account_broker.py +3 -0
- package/scripts/test_account_broker.py +46 -2
|
@@ -205,6 +205,9 @@ The recovery command restores the named account and deletes only that record.
|
|
|
205
205
|
`--confirm-inactive` before recovery.
|
|
206
206
|
- Normal pull request actions never call `gh auth switch`. If that command
|
|
207
207
|
appears outside explicit recovery, stop.
|
|
208
|
+
- `gh auth switch`, `login`, and `logout` change the account for every session
|
|
209
|
+
on the machine, and a PreToolUse hook denies them. Scope another account to
|
|
210
|
+
one command: `GH_TOKEN=$(gh auth token -u <account>) gh <command>`.
|
|
208
211
|
- GitHub CLI output can contain account data. The command captures account
|
|
209
212
|
lookup and recovery output and prints only generic errors.
|
|
210
213
|
|
package/docs/account-broker.md
CHANGED
|
@@ -27,7 +27,7 @@ The broker stores cached meters, spent marks, and Claude session bindings in one
|
|
|
27
27
|
| `0` | `choose` or `check` found an account, or the job exited successfully. |
|
|
28
28
|
| `3` | No account clears its floor. The decision includes `resets_at`. |
|
|
29
29
|
| `4` | A Claude job stopped with `advisor_blocked`. |
|
|
30
|
-
| `127` | A Codex job timed out,
|
|
30
|
+
| `127` | A Codex job timed out, its command could not start while no Codex roster is configured, or the command could not start on any account in the roster. |
|
|
31
31
|
| Other command code | `run` returns the command's exit code after a served response. |
|
|
32
32
|
|
|
33
33
|
`run` writes a JSON report with `product`, `command`, `events`, and `final_decision`. A wait report has `final_decision.action` equal to `wait` and a UTC `final_decision.resets_at`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse gate: keep agents from changing the global GitHub CLI account.
|
|
3
|
+
|
|
4
|
+
``gh auth switch``, ``gh auth login``, and ``gh auth logout`` write the shared
|
|
5
|
+
gh configuration, so they change the active account for every session on the
|
|
6
|
+
machine. A command segment whose program is ``gh`` and whose arguments start
|
|
7
|
+
with one of those subcommands is denied, and the deny reason names the
|
|
8
|
+
per-command ``GH_TOKEN`` form to run instead. The program is read past wrappers
|
|
9
|
+
and chained commands, so ``cd x && gh auth switch`` and ``bash -c "gh auth
|
|
10
|
+
logout"`` are denied too. ``gh auth status``, ``gh auth token``, and quoted text
|
|
11
|
+
that only mentions the words pass.
|
|
12
|
+
|
|
13
|
+
Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
|
|
14
|
+
PowerShell tools.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
25
|
+
if _hooks_root_directory not in sys.path:
|
|
26
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
27
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
28
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
29
|
+
DENY_DECISION,
|
|
30
|
+
HOOK_EVENT_NAME,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.gh_global_account_switch_gate_constants import (
|
|
33
|
+
ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS,
|
|
34
|
+
GATE_HOOK_NAME,
|
|
35
|
+
GH_COMMAND_PATH_LENGTH,
|
|
36
|
+
GH_PROGRAM_NAME,
|
|
37
|
+
GLOBAL_ACCOUNT_CHANGE_DENY_REASON,
|
|
38
|
+
)
|
|
39
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
40
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
41
|
+
from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
|
|
42
|
+
from hooks_constants.shell_command_wrappers import (
|
|
43
|
+
all_wrapped_command_texts,
|
|
44
|
+
segment_program_and_arguments,
|
|
45
|
+
)
|
|
46
|
+
except ImportError as import_error:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"The gh global account switch gate cannot import its dependencies; "
|
|
49
|
+
"ensure the hooks directory is importable."
|
|
50
|
+
) from import_error
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _changes_global_gh_account(all_segment_tokens: list[str]) -> bool:
|
|
54
|
+
"""Return True when a segment runs gh auth switch, login, or logout.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
all_segment_tokens: One command segment's shell tokens.
|
|
58
|
+
"""
|
|
59
|
+
program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
60
|
+
command_path = tuple(all_arguments[:GH_COMMAND_PATH_LENGTH])
|
|
61
|
+
return program_name.lower() == GH_PROGRAM_NAME and command_path in ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def changes_global_gh_account(command: str) -> bool:
|
|
65
|
+
"""Return True when any command segment changes the machine-wide gh account.
|
|
66
|
+
|
|
67
|
+
::
|
|
68
|
+
|
|
69
|
+
gh auth switch -u <account> -> True
|
|
70
|
+
git fetch && gh auth login -> True
|
|
71
|
+
GH_TOKEN=$(gh auth token -u <account>) gh repo list -> False
|
|
72
|
+
git commit -m "never run gh auth switch" -> False
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
command: The shell command text the agent is about to run.
|
|
76
|
+
"""
|
|
77
|
+
return any(
|
|
78
|
+
_changes_global_gh_account(each_segment)
|
|
79
|
+
for each_text in all_wrapped_command_texts(command)
|
|
80
|
+
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
"""Deny a command that changes the global gh account, or stay quiet."""
|
|
86
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
87
|
+
if hook_payload is None:
|
|
88
|
+
return
|
|
89
|
+
tool_name = hook_payload.get("tool_name")
|
|
90
|
+
if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
|
|
91
|
+
return
|
|
92
|
+
tool_input = hook_payload.get("tool_input")
|
|
93
|
+
if not isinstance(tool_input, dict):
|
|
94
|
+
return
|
|
95
|
+
command = tool_input.get("command", "")
|
|
96
|
+
if not isinstance(command, str) or not changes_global_gh_account(command):
|
|
97
|
+
return
|
|
98
|
+
log_hook_block(
|
|
99
|
+
GATE_HOOK_NAME, HOOK_EVENT_NAME, GLOBAL_ACCOUNT_CHANGE_DENY_REASON, str(tool_name), command
|
|
100
|
+
)
|
|
101
|
+
sys.stdout.write(
|
|
102
|
+
json.dumps(
|
|
103
|
+
{
|
|
104
|
+
"hookSpecificOutput": {
|
|
105
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
106
|
+
"permissionDecision": DENY_DECISION,
|
|
107
|
+
"permissionDecisionReason": GLOBAL_ACCOUNT_CHANGE_DENY_REASON,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
sys.stdout.flush()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A Bash tool call starts one interpreter, the dispatcher's own. Each roster
|
|
4
4
|
entry then runs in that process through runpy, so the roster's exact content is
|
|
5
|
-
the chain's cost after that single start. The roster holds the MSYS rewriter
|
|
6
|
-
the headless Claude broker gate
|
|
5
|
+
the chain's cost after that single start. The roster holds the MSYS rewriter,
|
|
6
|
+
the headless Claude broker gate, and the gh global account gate; any other hook
|
|
7
|
+
added here fails this test.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import sys
|
|
@@ -21,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
def
|
|
25
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
|
|
25
26
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
26
27
|
BashHostedHookEntry(
|
|
27
28
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -31,4 +32,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_broker_gate() -> None
|
|
|
31
32
|
script_relative_path="blocking/headless_claude_broker_gate.py",
|
|
32
33
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
33
34
|
),
|
|
35
|
+
BashHostedHookEntry(
|
|
36
|
+
script_relative_path="blocking/gh_global_account_switch_gate.py",
|
|
37
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
38
|
+
),
|
|
34
39
|
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for gh_global_account_switch_gate, which keeps the global gh account fixed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import gh_global_account_switch_gate as gate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
15
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
16
|
+
captured_stdout = StringIO()
|
|
17
|
+
with (
|
|
18
|
+
patch("sys.stdin", StringIO(json.dumps(payload))),
|
|
19
|
+
patch("sys.stdout", captured_stdout),
|
|
20
|
+
patch.object(gate, "log_hook_block"),
|
|
21
|
+
):
|
|
22
|
+
gate.main()
|
|
23
|
+
return captured_stdout.getvalue()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize(
|
|
27
|
+
"command",
|
|
28
|
+
[
|
|
29
|
+
"gh auth switch",
|
|
30
|
+
"gh auth switch -u account-a",
|
|
31
|
+
"gh auth login",
|
|
32
|
+
"gh auth login --with-token",
|
|
33
|
+
"gh auth logout -u account-b",
|
|
34
|
+
"gh.exe auth switch -u account-b",
|
|
35
|
+
"cd repo && gh auth switch -u account-a",
|
|
36
|
+
"gh auth status; gh auth switch -u account-a",
|
|
37
|
+
"gh auth status || gh auth login",
|
|
38
|
+
'bash -c "gh auth switch -u account-a"',
|
|
39
|
+
'pwsh -Command "gh auth logout"',
|
|
40
|
+
"& gh auth switch",
|
|
41
|
+
"sudo gh auth switch --user account-b",
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
def test_global_account_change_is_detected(command: str) -> None:
|
|
45
|
+
assert gate.changes_global_gh_account(command) is True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"command",
|
|
50
|
+
[
|
|
51
|
+
"gh auth status",
|
|
52
|
+
"gh auth status --active",
|
|
53
|
+
"gh auth token -u account-a",
|
|
54
|
+
"GH_TOKEN=$(gh auth token -u account-a) gh repo list",
|
|
55
|
+
"$env:GH_TOKEN = gh auth token -u account-a; gh repo list; Remove-Item Env:GH_TOKEN",
|
|
56
|
+
"gh pr view 12",
|
|
57
|
+
"gh api user",
|
|
58
|
+
"gh",
|
|
59
|
+
'git commit -m "never call gh auth switch"',
|
|
60
|
+
'echo "gh auth switch"',
|
|
61
|
+
'grep -rn "gh auth login" docs',
|
|
62
|
+
"git switch main",
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
def test_read_only_and_unrelated_commands_pass(command: str) -> None:
|
|
66
|
+
assert gate.changes_global_gh_account(command) is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_bash_payload_with_switch_is_denied_with_the_scoped_token_forms() -> None:
|
|
70
|
+
decision = json.loads(
|
|
71
|
+
_stdout_from_main(
|
|
72
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh auth switch -u account-a"}}
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
specific_output = decision["hookSpecificOutput"]
|
|
76
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
77
|
+
reason = specific_output["permissionDecisionReason"]
|
|
78
|
+
assert "GH_TOKEN=$(gh auth token -u <account>) gh <command>" in reason
|
|
79
|
+
assert (
|
|
80
|
+
"$env:GH_TOKEN = gh auth token -u <account>; gh <command>; Remove-Item Env:GH_TOKEN"
|
|
81
|
+
in reason
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_powershell_payload_with_logout_is_denied() -> None:
|
|
86
|
+
decision = json.loads(
|
|
87
|
+
_stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": "gh auth logout"}})
|
|
88
|
+
)
|
|
89
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_scoped_token_payload_emits_nothing() -> None:
|
|
93
|
+
scoped_command = "GH_TOKEN=$(gh auth token -u account-b) gh repo list"
|
|
94
|
+
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": scoped_command}}) == ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_other_tool_payload_emits_nothing() -> None:
|
|
98
|
+
assert (
|
|
99
|
+
_stdout_from_main({"tool_name": "Write", "tool_input": {"command": "gh auth switch"}}) == ""
|
|
100
|
+
)
|
|
@@ -63,4 +63,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
63
63
|
script_relative_path="blocking/headless_claude_broker_gate.py",
|
|
64
64
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
65
65
|
),
|
|
66
|
+
BashHostedHookEntry(
|
|
67
|
+
script_relative_path="blocking/gh_global_account_switch_gate.py",
|
|
68
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
69
|
+
),
|
|
66
70
|
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Constants for the gate that keeps agents from changing the global gh account.
|
|
2
|
+
|
|
3
|
+
Holds the GitHub CLI program name, the ``gh auth`` command paths that change the
|
|
4
|
+
account every session on the machine uses and their word count, the hook name
|
|
5
|
+
the block log records, and the deny message that names the per-command token
|
|
6
|
+
form to run instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"GH_PROGRAM_NAME",
|
|
13
|
+
"GH_COMMAND_PATH_LENGTH",
|
|
14
|
+
"ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS",
|
|
15
|
+
"GATE_HOOK_NAME",
|
|
16
|
+
"GLOBAL_ACCOUNT_CHANGE_DENY_REASON",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
GH_PROGRAM_NAME: str = "gh"
|
|
20
|
+
GH_COMMAND_PATH_LENGTH: int = 2
|
|
21
|
+
ALL_GLOBAL_ACCOUNT_CHANGING_COMMAND_PATHS: frozenset[tuple[str, ...]] = frozenset(
|
|
22
|
+
{("auth", "switch"), ("auth", "login"), ("auth", "logout")}
|
|
23
|
+
)
|
|
24
|
+
GATE_HOOK_NAME: str = "gh_global_account_switch_gate.py"
|
|
25
|
+
GLOBAL_ACCOUNT_CHANGE_DENY_REASON: str = (
|
|
26
|
+
"BLOCKED: gh auth switch, login, and logout change the active GitHub CLI account "
|
|
27
|
+
"for every session on this machine, so other running agents lose access. "
|
|
28
|
+
"Scope the other signed-in account to one command instead. "
|
|
29
|
+
"Bash: GH_TOKEN=$(gh auth token -u <account>) gh <command>. "
|
|
30
|
+
"PowerShell: $env:GH_TOKEN = gh auth token -u <account>; gh <command>; Remove-Item Env:GH_TOKEN. "
|
|
31
|
+
"Run gh auth status to list the signed-in accounts. "
|
|
32
|
+
"Signing an account in or out is a step for the user to run."
|
|
33
|
+
)
|
|
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
|
|
|
12
12
|
BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate() -> None:
|
|
16
16
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
17
17
|
BashHostedHookEntry(
|
|
18
18
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -22,4 +22,8 @@ def test_roster_hosts_only_the_msys_rewriter_and_the_broker_gate() -> None:
|
|
|
22
22
|
script_relative_path="blocking/headless_claude_broker_gate.py",
|
|
23
23
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
24
24
|
),
|
|
25
|
+
BashHostedHookEntry(
|
|
26
|
+
script_relative_path="blocking/gh_global_account_switch_gate.py",
|
|
27
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
28
|
+
),
|
|
25
29
|
)
|
|
@@ -31,6 +31,7 @@ def test_should_move_the_spawn_to_opus(requested_model: object) -> None:
|
|
|
31
31
|
if requested_model is not None:
|
|
32
32
|
tool_input["model"] = requested_model
|
|
33
33
|
decision = json.loads(_run_hook(tool_input))["hookSpecificOutput"]
|
|
34
|
+
assert decision["hookEventName"] == "PreToolUse"
|
|
34
35
|
assert "permissionDecision" not in decision
|
|
35
36
|
assert decision["updatedInput"] == {**SPAWN_INPUT, "model": "opus"}
|
|
36
37
|
|
package/package.json
CHANGED
|
@@ -234,6 +234,9 @@ def _prepare_run(
|
|
|
234
234
|
|
|
235
235
|
def _wait_outcome(context: _RunContext, decision: Decision) -> JobOutcome:
|
|
236
236
|
context.report.final_decision = decision
|
|
237
|
+
all_attempt_statuses = {each_status for _, each_status in context.all_attempts}
|
|
238
|
+
if all_attempt_statuses == {"start_failed"}:
|
|
239
|
+
return JobOutcome(COMMAND_MISSING_EXIT_CODE, "", "", None, tuple(context.all_attempts), "start_failed", None, None)
|
|
237
240
|
status = "exhausted" if context.all_attempts else "wait"
|
|
238
241
|
return JobOutcome(WAIT_EXIT_CODE, "", "", None, tuple(context.all_attempts), status, None, decision.resets_at)
|
|
239
242
|
|
|
@@ -418,6 +418,50 @@ def test_should_leave_later_choices_open_after_start_failure(
|
|
|
418
418
|
assert json.loads(capsys.readouterr().out)["decision"]["account"] == "first"
|
|
419
419
|
|
|
420
420
|
|
|
421
|
+
@pytest.mark.parametrize("product", (Product.CLAUDE, Product.CODEX))
|
|
422
|
+
def test_should_exit_127_when_every_account_fails_to_start(
|
|
423
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, product: Product
|
|
424
|
+
) -> None:
|
|
425
|
+
accounts = (_account("first", product), _account("second", product))
|
|
426
|
+
monkeypatch.setitem(account_broker.all_product_adapters, product, _adapter(accounts, {
|
|
427
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
428
|
+
}))
|
|
429
|
+
monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(b""), encoding="utf-8"))
|
|
430
|
+
|
|
431
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
432
|
+
raise OSError("missing command")
|
|
433
|
+
|
|
434
|
+
with account_broker.override_subprocess_runner(runner):
|
|
435
|
+
outcome, _ = account_broker._execute(product, ("job",), now=datetime.now(timezone.utc))
|
|
436
|
+
code = account_broker.main(("run", "--product", product.value, "--report", str(tmp_path / "report.json"), "--", "job"))
|
|
437
|
+
|
|
438
|
+
assert outcome.status == "start_failed"
|
|
439
|
+
assert outcome.returncode == 127
|
|
440
|
+
assert outcome.wait_reset_at is None
|
|
441
|
+
assert code == 127
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_should_report_exhausted_when_start_failure_meets_usage_limit(
|
|
445
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
446
|
+
) -> None:
|
|
447
|
+
accounts = (_account("first"), _account("second"))
|
|
448
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
|
|
449
|
+
"first": _meters(80, 80), "second": _meters(70, 70)
|
|
450
|
+
}))
|
|
451
|
+
|
|
452
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
453
|
+
if Path(options["env"]["CODEX_HOME"]).name == "first":
|
|
454
|
+
raise OSError("missing command")
|
|
455
|
+
return subprocess.CompletedProcess(argv, 1, "", "rate limit")
|
|
456
|
+
|
|
457
|
+
with account_broker.override_subprocess_runner(runner):
|
|
458
|
+
outcome, _ = account_broker._execute(Product.CODEX, ("job",), now=datetime.now(timezone.utc))
|
|
459
|
+
|
|
460
|
+
assert outcome.attempts == (("first", "start_failed"), ("second", "usage_limited"))
|
|
461
|
+
assert outcome.status == "exhausted"
|
|
462
|
+
assert outcome.returncode == 3
|
|
463
|
+
|
|
464
|
+
|
|
421
465
|
def test_should_stop_after_timeout_without_running_job_again(
|
|
422
466
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
423
467
|
) -> None:
|
|
@@ -707,8 +751,8 @@ def test_should_record_start_failure_without_launching_a_command_missing_from_pa
|
|
|
707
751
|
outcome, _ = account_broker._execute(Product.CLAUDE, ("claude", "-p"), now=NOW)
|
|
708
752
|
|
|
709
753
|
assert outcome.attempts == (("extra", "start_failed"),)
|
|
710
|
-
assert outcome.status == "
|
|
711
|
-
assert outcome.returncode ==
|
|
754
|
+
assert outcome.status == "start_failed"
|
|
755
|
+
assert outcome.returncode == 127
|
|
712
756
|
|
|
713
757
|
|
|
714
758
|
@pytest.mark.parametrize("marked_name", ["retired", "first"])
|