claude-dev-env 8.47.3 → 8.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/build-eval/reference/graders-and-commands.md +3 -3
- package/.agents/skills/team-advisor/reference/advisor-docs-review.md +5 -3
- package/docs/account-broker.md +1 -1
- package/hooks/blocking/headless_claude_broker_gate.py +142 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +8 -3
- package/hooks/blocking/test_headless_claude_broker_gate.py +88 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +35 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +7 -1
- package/scripts/account_broker_support.py +25 -1
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +7 -0
- package/scripts/test_account_broker.py +48 -0
- package/scripts/test_account_broker_support.py +59 -0
|
@@ -89,11 +89,11 @@ writes were all denied still shows the tool_use attempts.
|
|
|
89
89
|
|
|
90
90
|
## Flag-gated features outside plugin eval
|
|
91
91
|
|
|
92
|
-
The sandbox cannot turn on a flag-gated feature. Test it with `claude -p
|
|
93
|
-
loaded and once without:
|
|
92
|
+
The sandbox cannot turn on a flag-gated feature. Test it with a headless `claude -p` run started
|
|
93
|
+
through the account broker, once with the wrapper loaded and once without:
|
|
94
94
|
|
|
95
95
|
```text
|
|
96
|
-
claude -p "<task>" --advisor <model> --plugin-dir <wrapper> --setting-sources project --strict-mcp-config --allowedTools "<list>" --permission-mode dontAsk --output-format stream-json --verbose
|
|
96
|
+
python "$HOME/.claude/scripts/account_broker.py" run --product claude --report <report.json> -- claude -p "<task>" --advisor <model> --plugin-dir <wrapper> --setting-sources project --strict-mcp-config --allowedTools "<list>" --permission-mode dontAsk --output-format stream-json --verbose
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
Drop `--plugin-dir <wrapper>` for the second arm.
|
|
@@ -213,11 +213,13 @@ cannot reach the built-in tool path. Its sandbox turns off feature-flag
|
|
|
213
213
|
fetching, and `/advisor` replies "isn't available in this environment". A
|
|
214
214
|
plugin-eval suite therefore grades the warm-agent path alone.
|
|
215
215
|
|
|
216
|
-
Drive the built-in tool path with a headless run
|
|
217
|
-
without:
|
|
216
|
+
Drive the built-in tool path with a headless run through the account broker,
|
|
217
|
+
once with the skill and once without:
|
|
218
218
|
|
|
219
219
|
```text
|
|
220
|
-
|
|
220
|
+
python "$HOME/.claude/scripts/account_broker.py" run --product claude \
|
|
221
|
+
--report <report.json> -- \
|
|
222
|
+
claude -p "<task>" --advisor opus --plugin-dir <wrapper> \
|
|
221
223
|
--setting-sources project --strict-mcp-config \
|
|
222
224
|
--allowedTools "Read,Glob,Grep,Skill,Agent,SendMessage,Write,Edit" \
|
|
223
225
|
--permission-mode dontAsk --output-format stream-json --verbose
|
package/docs/account-broker.md
CHANGED
|
@@ -16,7 +16,7 @@ python scripts/account_broker.py run --product claude --report report.json -- cl
|
|
|
16
16
|
|
|
17
17
|
`accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
|
|
18
18
|
|
|
19
|
-
`check` prints nothing and exits 3 while every account is below its floor. `run` sets
|
|
19
|
+
`check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
|
|
20
20
|
|
|
21
21
|
The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. Every broker process for the same user reads that file, so a spent mark pauses that account for every job. A write holds an exclusive lock on the sibling `state.json.lock`, re-reads the file, merges, and swaps in a sibling temporary file with `os.replace`. The merge keeps the latest reset for each spent mark and the newest meter read for each account.
|
|
22
22
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse gate: start every headless Claude session through the account broker.
|
|
3
|
+
|
|
4
|
+
A command segment whose program is ``claude`` or a ``claude-<profile>``
|
|
5
|
+
launcher and that passes ``-p`` or ``--print`` is denied, and the deny reason
|
|
6
|
+
names the broker command to run instead. The program is read past wrappers,
|
|
7
|
+
so ``env -u NAME claude -p`` and ``bash -c "claude -p task"`` are denied too.
|
|
8
|
+
The broker form runs ``python``, so its ``-- claude -p ...`` tail passes.
|
|
9
|
+
Interactive ``claude`` and its subcommands, such as ``claude plugin eval`` or
|
|
10
|
+
``claude --version``, pass.
|
|
11
|
+
|
|
12
|
+
Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
|
|
13
|
+
PowerShell tools.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
24
|
+
if _hooks_root_directory not in sys.path:
|
|
25
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
26
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
27
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
28
|
+
DENY_DECISION,
|
|
29
|
+
HOOK_EVENT_NAME,
|
|
30
|
+
)
|
|
31
|
+
from hooks_constants.headless_claude_broker_gate_constants import (
|
|
32
|
+
ALL_PRINT_MODE_FLAGS,
|
|
33
|
+
ALL_WINDOWS_LAUNCHER_SUFFIXES,
|
|
34
|
+
BROKER_COMMAND_DENY_REASON,
|
|
35
|
+
CLAUDE_PROFILE_LAUNCHER_PREFIX,
|
|
36
|
+
CLAUDE_PROGRAM_NAME,
|
|
37
|
+
GATE_HOOK_NAME,
|
|
38
|
+
PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX,
|
|
39
|
+
)
|
|
40
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
41
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
42
|
+
from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
|
|
43
|
+
from hooks_constants.shell_command_wrappers import (
|
|
44
|
+
all_wrapped_command_texts,
|
|
45
|
+
segment_program_and_arguments,
|
|
46
|
+
)
|
|
47
|
+
except ImportError as import_error:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"The headless Claude broker gate cannot import its dependencies; "
|
|
50
|
+
"ensure the hooks directory is importable."
|
|
51
|
+
) from import_error
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_claude_program(program_name: str) -> bool:
|
|
55
|
+
"""Return True when the program name launches Claude Code or a Claude profile launcher.
|
|
56
|
+
|
|
57
|
+
::
|
|
58
|
+
|
|
59
|
+
claude -> True
|
|
60
|
+
claude-ev.cmd -> True
|
|
61
|
+
claude_account_worker.py -> False
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
program_name: A segment's program basename, read past its wrappers.
|
|
65
|
+
"""
|
|
66
|
+
launcher_name = program_name
|
|
67
|
+
for each_suffix in ALL_WINDOWS_LAUNCHER_SUFFIXES:
|
|
68
|
+
launcher_name = launcher_name.removesuffix(each_suffix)
|
|
69
|
+
if launcher_name == CLAUDE_PROGRAM_NAME:
|
|
70
|
+
return True
|
|
71
|
+
profile_name = launcher_name.removeprefix(CLAUDE_PROFILE_LAUNCHER_PREFIX)
|
|
72
|
+
return profile_name != launcher_name and profile_name.replace("_", "").replace("-", "").isalnum()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_print_mode_flag(argument: str) -> bool:
|
|
76
|
+
"""Return True when one argument turns on Claude's print mode.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
argument: One argument after the segment's program.
|
|
80
|
+
"""
|
|
81
|
+
return argument in ALL_PRINT_MODE_FLAGS or argument.startswith(PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _runs_headless_claude(all_segment_tokens: list[str]) -> bool:
|
|
85
|
+
"""Return True when a segment's program is a Claude launcher in print mode.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
all_segment_tokens: One command segment's shell tokens.
|
|
89
|
+
"""
|
|
90
|
+
program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
91
|
+
return _is_claude_program(program_name) and any(
|
|
92
|
+
_is_print_mode_flag(each_argument) for each_argument in all_arguments
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def starts_unbrokered_headless_claude(command: str) -> bool:
|
|
97
|
+
"""Return True when a command segment starts headless Claude without the broker.
|
|
98
|
+
|
|
99
|
+
::
|
|
100
|
+
|
|
101
|
+
claude -p "task" -> True
|
|
102
|
+
cd repo && env -u CLAUDECODE claude-ev -p "task" -> True
|
|
103
|
+
python account_broker.py run ... -- claude -p "task" -> False
|
|
104
|
+
git -C claude-dev-env log -p -> False
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
command: The shell command text the agent is about to run.
|
|
108
|
+
"""
|
|
109
|
+
return any(
|
|
110
|
+
_runs_headless_claude(each_segment)
|
|
111
|
+
for each_text in all_wrapped_command_texts(command)
|
|
112
|
+
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
"""Deny a headless Claude start that skips the broker, or stay quiet."""
|
|
118
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
119
|
+
if hook_payload is None:
|
|
120
|
+
return
|
|
121
|
+
tool_name = hook_payload.get("tool_name")
|
|
122
|
+
if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
|
|
123
|
+
return
|
|
124
|
+
tool_input = hook_payload.get("tool_input")
|
|
125
|
+
if not isinstance(tool_input, dict):
|
|
126
|
+
return
|
|
127
|
+
command = tool_input.get("command", "")
|
|
128
|
+
if not isinstance(command, str) or not starts_unbrokered_headless_claude(command):
|
|
129
|
+
return
|
|
130
|
+
log_hook_block(GATE_HOOK_NAME, HOOK_EVENT_NAME, BROKER_COMMAND_DENY_REASON, str(tool_name), command)
|
|
131
|
+
sys.stdout.write(json.dumps({
|
|
132
|
+
"hookSpecificOutput": {
|
|
133
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
134
|
+
"permissionDecision": DENY_DECISION,
|
|
135
|
+
"permissionDecisionReason": BROKER_COMMAND_DENY_REASON,
|
|
136
|
+
}
|
|
137
|
+
}))
|
|
138
|
+
sys.stdout.flush()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
|
@@ -2,8 +2,8 @@
|
|
|
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.
|
|
6
|
-
|
|
5
|
+
the chain's cost after that single start. The roster holds the MSYS rewriter and
|
|
6
|
+
the headless Claude broker gate; any other hook added here fails this test.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import sys
|
|
@@ -14,16 +14,21 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
14
14
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
15
15
|
|
|
16
16
|
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
17
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
17
18
|
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
18
19
|
ALL_BASH_ONLY_TOOL_NAMES,
|
|
19
20
|
BashHostedHookEntry,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
def
|
|
24
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_broker_gate() -> None:
|
|
24
25
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
25
26
|
BashHostedHookEntry(
|
|
26
27
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
27
28
|
applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
|
|
28
29
|
),
|
|
30
|
+
BashHostedHookEntry(
|
|
31
|
+
script_relative_path="blocking/headless_claude_broker_gate.py",
|
|
32
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
33
|
+
),
|
|
29
34
|
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Tests for headless_claude_broker_gate, which sends headless Claude through the broker."""
|
|
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 headless_claude_broker_gate as gate
|
|
12
|
+
|
|
13
|
+
BROKER_COMMAND = (
|
|
14
|
+
'python "$HOME/.claude/scripts/account_broker.py" run --product claude --report r.json '
|
|
15
|
+
'-- claude -p "task" --output-format stream-json --verbose'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
20
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
21
|
+
captured_stdout = StringIO()
|
|
22
|
+
with patch("sys.stdin", StringIO(json.dumps(payload))), patch("sys.stdout", captured_stdout), patch.object(
|
|
23
|
+
gate, "log_hook_block"
|
|
24
|
+
):
|
|
25
|
+
gate.main()
|
|
26
|
+
return captured_stdout.getvalue()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize(
|
|
30
|
+
"command",
|
|
31
|
+
[
|
|
32
|
+
'claude -p "fix the test"',
|
|
33
|
+
'claude --print "fix the test"',
|
|
34
|
+
'claude -p --resume abc-123 "next turn" --output-format stream-json --verbose',
|
|
35
|
+
'cd /repo && claude -p "task"',
|
|
36
|
+
'env -u CLAUDECODE claude -p "task"',
|
|
37
|
+
'CLAUDE_CONFIG_DIR=/x claude -p "task"',
|
|
38
|
+
'claude-ev -p "task"',
|
|
39
|
+
"claude-ev.cmd -p task",
|
|
40
|
+
"/opt/node22/bin/claude -p task",
|
|
41
|
+
"& claude -p 'task'",
|
|
42
|
+
"timeout 600 claude -p task > out.json",
|
|
43
|
+
'bash -c "claude -p task"',
|
|
44
|
+
"sudo -u builder claude -p task",
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
def test_headless_claude_without_the_broker_is_detected(command: str) -> None:
|
|
48
|
+
assert gate.starts_unbrokered_headless_claude(command) is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize(
|
|
52
|
+
"command",
|
|
53
|
+
[
|
|
54
|
+
BROKER_COMMAND,
|
|
55
|
+
"python ~/.claude/scripts/claude_account_worker.py --prompt-file b.md --report-file r.json",
|
|
56
|
+
"claude plugin eval suite.json",
|
|
57
|
+
"claude --version",
|
|
58
|
+
"claude",
|
|
59
|
+
'grep -rn "claude -p" docs',
|
|
60
|
+
'echo "run claude -p later"',
|
|
61
|
+
"python claude_account_worker.py -p x",
|
|
62
|
+
"git -C claude-dev-env log -p",
|
|
63
|
+
"mkdir -p claude-dev-env",
|
|
64
|
+
"cp -rp claude-dev-env /backup -p",
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
def test_broker_starts_and_other_claude_commands_pass(command: str) -> None:
|
|
68
|
+
assert gate.starts_unbrokered_headless_claude(command) is False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_bash_payload_with_bare_headless_claude_is_denied_with_the_broker_command() -> None:
|
|
72
|
+
decision = json.loads(_stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}))
|
|
73
|
+
specific_output = decision["hookSpecificOutput"]
|
|
74
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
75
|
+
assert "account_broker.py\" run --product claude" in specific_output["permissionDecisionReason"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_powershell_payload_with_profile_launcher_is_denied() -> None:
|
|
79
|
+
decision = json.loads(_stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": "claude-ev -p task"}}))
|
|
80
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_broker_command_payload_emits_nothing() -> None:
|
|
84
|
+
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": BROKER_COMMAND}}) == ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_other_tool_payload_emits_nothing() -> None:
|
|
88
|
+
assert _stdout_from_main({"tool_name": "Write", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
package/hooks/hooks.json
CHANGED
|
@@ -67,6 +67,16 @@
|
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"matcher": "PowerShell",
|
|
72
|
+
"hooks": [
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/bash_pre_tool_use_dispatcher.py",
|
|
76
|
+
"timeout": 60
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
70
80
|
{
|
|
71
81
|
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
|
|
72
82
|
"hooks": [
|
|
@@ -59,4 +59,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
59
59
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
60
60
|
applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
|
|
61
61
|
),
|
|
62
|
+
BashHostedHookEntry(
|
|
63
|
+
script_relative_path="blocking/headless_claude_broker_gate.py",
|
|
64
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
65
|
+
),
|
|
62
66
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Constants for the headless Claude broker gate.
|
|
2
|
+
|
|
3
|
+
Holds the Claude program name and its profile-launcher prefix, the Windows
|
|
4
|
+
launcher suffixes stripped before the name check, the two print-mode flags, the
|
|
5
|
+
hook name the block log records, and the deny message that names the broker
|
|
6
|
+
command to run instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CLAUDE_PROGRAM_NAME",
|
|
13
|
+
"CLAUDE_PROFILE_LAUNCHER_PREFIX",
|
|
14
|
+
"ALL_WINDOWS_LAUNCHER_SUFFIXES",
|
|
15
|
+
"ALL_PRINT_MODE_FLAGS",
|
|
16
|
+
"PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX",
|
|
17
|
+
"GATE_HOOK_NAME",
|
|
18
|
+
"BROKER_COMMAND_DENY_REASON",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
CLAUDE_PROGRAM_NAME: str = "claude"
|
|
22
|
+
CLAUDE_PROFILE_LAUNCHER_PREFIX: str = "claude-"
|
|
23
|
+
ALL_WINDOWS_LAUNCHER_SUFFIXES: tuple[str, ...] = (".cmd", ".exe", ".ps1", ".bat")
|
|
24
|
+
ALL_PRINT_MODE_FLAGS: frozenset[str] = frozenset({"-p", "--print"})
|
|
25
|
+
PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX: str = "--print="
|
|
26
|
+
GATE_HOOK_NAME: str = "headless_claude_broker_gate.py"
|
|
27
|
+
BROKER_COMMAND_DENY_REASON: str = (
|
|
28
|
+
"BLOCKED: Start a headless Claude session through the account broker. "
|
|
29
|
+
"Run: python \"$HOME/.claude/scripts/account_broker.py\" run --product claude "
|
|
30
|
+
"--report <report.json> -- <the same claude -p command>. "
|
|
31
|
+
"The broker picks an account with room, sends a --resume turn to the account that owns the session, "
|
|
32
|
+
"and starts the child without this session's variables. "
|
|
33
|
+
"Exit 3 means no account has room: report the reset time from the report file. "
|
|
34
|
+
"For a worker that edits code, use claude_account_worker.py from the second-account-workers skill."
|
|
35
|
+
)
|
|
@@ -8,13 +8,18 @@ ALL_CONSTANT_BINDINGS = run_path(
|
|
|
8
8
|
)
|
|
9
9
|
ALL_BASH_HOSTED_HOOK_ENTRIES = ALL_CONSTANT_BINDINGS["ALL_BASH_HOSTED_HOOK_ENTRIES"]
|
|
10
10
|
ALL_BASH_ONLY_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_ONLY_TOOL_NAMES"]
|
|
11
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSHELL_TOOL_NAMES"]
|
|
11
12
|
BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
+
def test_roster_hosts_only_the_msys_rewriter_and_the_broker_gate() -> None:
|
|
15
16
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
16
17
|
BashHostedHookEntry(
|
|
17
18
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
18
19
|
applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
|
|
19
20
|
),
|
|
21
|
+
BashHostedHookEntry(
|
|
22
|
+
script_relative_path="blocking/headless_claude_broker_gate.py",
|
|
23
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
24
|
+
),
|
|
20
25
|
)
|
package/package.json
CHANGED
|
@@ -45,6 +45,7 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
45
45
|
_choose_claude,
|
|
46
46
|
_choose_codex,
|
|
47
47
|
_wait_decision,
|
|
48
|
+
ALL_PARENT_CLAUDE_SESSION_VARIABLES,
|
|
48
49
|
COMMAND_MISSING_EXIT_CODE,
|
|
49
50
|
REPORT_INDENT_SPACES,
|
|
50
51
|
WAIT_EXIT_CODE,
|
|
@@ -248,7 +249,12 @@ def _record_spent_attempt(context: _RunContext, account: Account, status: str, r
|
|
|
248
249
|
|
|
249
250
|
|
|
250
251
|
def _invoke(context: _RunContext, account: Account) -> subprocess.CompletedProcess[str]:
|
|
251
|
-
environment = {
|
|
252
|
+
environment = {
|
|
253
|
+
each_variable_name: each_setting
|
|
254
|
+
for each_variable_name, each_setting in os.environ.items()
|
|
255
|
+
if context.product is not Product.CLAUDE or each_variable_name not in ALL_PARENT_CLAUDE_SESSION_VARIABLES
|
|
256
|
+
}
|
|
257
|
+
environment[context.active.environment_variable] = str(account.home)
|
|
252
258
|
return support.subprocess_runner(
|
|
253
259
|
context.all_argv,
|
|
254
260
|
env=environment,
|
|
@@ -25,12 +25,14 @@ import codex_account_meters
|
|
|
25
25
|
from claude_account_profile import default_profile_home, validate_profile_name
|
|
26
26
|
from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
|
|
27
27
|
from dev_env_scripts_constants.account_broker_constants import (
|
|
28
|
+
ALL_BATCH_FILE_EXTENSIONS,
|
|
28
29
|
BROKER_STATE_DIRECTORY_NAME,
|
|
29
30
|
BROKER_STATE_FILE_NAME,
|
|
30
31
|
BROKER_STATE_LOCK_SUFFIX,
|
|
31
32
|
BROKER_STATE_TEMP_SUFFIX,
|
|
32
33
|
Account,
|
|
33
34
|
BrokerConfigurationError,
|
|
35
|
+
CMD_SHELL_METACHARACTERS,
|
|
34
36
|
Decision,
|
|
35
37
|
JobOutcome,
|
|
36
38
|
Meters,
|
|
@@ -434,7 +436,29 @@ def _resolve_command(all_argv: Sequence[str]) -> list[str]:
|
|
|
434
436
|
resolved_command = shutil.which(command_name)
|
|
435
437
|
if resolved_command is None and not os.path.dirname(command_name):
|
|
436
438
|
raise FileNotFoundError(errno.ENOENT, "command not found on PATH", command_name)
|
|
437
|
-
|
|
439
|
+
launched_command = resolved_command or command_name
|
|
440
|
+
_refuse_batch_file_shell_metacharacters(launched_command, all_arguments)
|
|
441
|
+
return [launched_command, *all_arguments]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _refuse_batch_file_shell_metacharacters(launched_command: str, all_arguments: Sequence[str]) -> None:
|
|
445
|
+
if os.path.splitext(launched_command)[1].casefold() not in ALL_BATCH_FILE_EXTENSIONS:
|
|
446
|
+
return
|
|
447
|
+
shell_parsed_argument = next(
|
|
448
|
+
(
|
|
449
|
+
each_argument
|
|
450
|
+
for each_argument in all_arguments
|
|
451
|
+
if any(each_character in CMD_SHELL_METACHARACTERS for each_character in each_argument)
|
|
452
|
+
),
|
|
453
|
+
None,
|
|
454
|
+
)
|
|
455
|
+
if shell_parsed_argument is None:
|
|
456
|
+
return
|
|
457
|
+
raise OSError(
|
|
458
|
+
errno.EINVAL,
|
|
459
|
+
f"cmd.exe would parse the batch file argument {shell_parsed_argument!r}; send that text on stdin",
|
|
460
|
+
launched_command,
|
|
461
|
+
)
|
|
438
462
|
|
|
439
463
|
|
|
440
464
|
def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]:
|
|
@@ -58,6 +58,29 @@ BROKER_STATE_FILE_NAME = "state.json"
|
|
|
58
58
|
BROKER_STATE_TEMP_SUFFIX = ".json"
|
|
59
59
|
BROKER_STATE_LOCK_SUFFIX = ".lock"
|
|
60
60
|
SECONDS_PER_HOUR = 3600
|
|
61
|
+
ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
|
|
62
|
+
CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
|
|
63
|
+
ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
|
|
64
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
65
|
+
"CLAUDE_CODE_REMOTE",
|
|
66
|
+
"CLAUDE_CODE_REMOTE_SESSION_ID",
|
|
67
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
68
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
69
|
+
"CLAUDE_SESSION_INGRESS_TOKEN_FILE",
|
|
70
|
+
"CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2",
|
|
71
|
+
"CLAUDE_CODE_TEE_SDK_STDOUT",
|
|
72
|
+
"CLAUDE_CODE_SYNC_SESSION_REFS",
|
|
73
|
+
"CLAUDE_CODE_CHILD_SESSION",
|
|
74
|
+
"CLAUDE_CODE_SESSION_ATTENDED",
|
|
75
|
+
"CLAUDECODE",
|
|
76
|
+
"CLAUDE_PID",
|
|
77
|
+
"CLAUDE_AFTER_LAST_COMPACT",
|
|
78
|
+
"CLAUDE_CODE_DIAGNOSTICS_FILE",
|
|
79
|
+
"CLAUDE_CODE_REMOTE_SEND_KEEPALIVES",
|
|
80
|
+
"CLAUDE_CODE_WORKER_EPOCH",
|
|
81
|
+
"CLAUDE_CODE_SYNC_SKILLS",
|
|
82
|
+
"CLAUDE_EFFORT",
|
|
83
|
+
})
|
|
61
84
|
|
|
62
85
|
|
|
63
86
|
def codex_usage_limit_signatures() -> tuple[str, ...]:
|
|
@@ -17,6 +17,7 @@ import pytest
|
|
|
17
17
|
from dev_env_scripts_constants import account_broker_constants as broker_constants
|
|
18
18
|
from dev_env_scripts_constants.account_broker_constants import (
|
|
19
19
|
ALL_CLAUDE_FLOORS,
|
|
20
|
+
ALL_PARENT_CLAUDE_SESSION_VARIABLES,
|
|
20
21
|
ALL_CODEX_FLOORS,
|
|
21
22
|
JobOutcome,
|
|
22
23
|
)
|
|
@@ -166,3 +167,9 @@ def test_codex_usage_limit_signatures_finds_shared_tree_beside_the_scripts_link(
|
|
|
166
167
|
)
|
|
167
168
|
|
|
168
169
|
assert installed_constants.codex_usage_limit_signatures() == (TEMP_TREE_MARKER,)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_parent_session_variables_name_the_session_link_and_leave_the_account_home() -> None:
|
|
173
|
+
assert "CLAUDE_CODE_SESSION_ID" in ALL_PARENT_CLAUDE_SESSION_VARIABLES
|
|
174
|
+
assert "CLAUDECODE" in ALL_PARENT_CLAUDE_SESSION_VARIABLES
|
|
175
|
+
assert "CLAUDE_CONFIG_DIR" not in ALL_PARENT_CLAUDE_SESSION_VARIABLES
|
|
@@ -824,3 +824,51 @@ def test_should_refuse_limits_for_claude(tmp_path: Path, capsys: pytest.CaptureF
|
|
|
824
824
|
account_broker.main(("limits", "--product", "claude", "--home", str(tmp_path)))
|
|
825
825
|
assert exit_info.value.code == 2
|
|
826
826
|
assert "invalid choice: 'claude'" in capsys.readouterr().err
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def test_should_start_claude_job_without_the_parent_session_variables(
|
|
830
|
+
monkeypatch: pytest.MonkeyPatch
|
|
831
|
+
) -> None:
|
|
832
|
+
accounts = (_account("first", Product.CLAUDE),)
|
|
833
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, _adapter(accounts, {
|
|
834
|
+
"first": _meters(80, 80)
|
|
835
|
+
}))
|
|
836
|
+
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "parent-session")
|
|
837
|
+
monkeypatch.setenv("CLAUDECODE", "1")
|
|
838
|
+
monkeypatch.setenv("CLAUDE_CODE_MESSAGING_SOCKET", "/parent/socket")
|
|
839
|
+
monkeypatch.setenv("UNRELATED_SETTING", "kept")
|
|
840
|
+
all_child_environments: list[dict[str, str]] = []
|
|
841
|
+
|
|
842
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
843
|
+
all_child_environments.append(options["env"])
|
|
844
|
+
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
845
|
+
|
|
846
|
+
with account_broker.override_subprocess_runner(runner):
|
|
847
|
+
run_job(Product.CLAUDE, ("claude", "-p", "task"))
|
|
848
|
+
|
|
849
|
+
child_environment = all_child_environments[0]
|
|
850
|
+
assert "CLAUDE_CODE_SESSION_ID" not in child_environment
|
|
851
|
+
assert "CLAUDECODE" not in child_environment
|
|
852
|
+
assert "CLAUDE_CODE_MESSAGING_SOCKET" not in child_environment
|
|
853
|
+
assert child_environment["UNRELATED_SETTING"] == "kept"
|
|
854
|
+
assert child_environment["CODEX_HOME"] == str(Path("/profiles") / "first")
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def test_should_keep_the_parent_environment_for_codex_jobs(
|
|
858
|
+
monkeypatch: pytest.MonkeyPatch
|
|
859
|
+
) -> None:
|
|
860
|
+
accounts = (_account("first"),)
|
|
861
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
|
|
862
|
+
"first": _meters(80, 80)
|
|
863
|
+
}))
|
|
864
|
+
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "parent-session")
|
|
865
|
+
all_child_environments: list[dict[str, str]] = []
|
|
866
|
+
|
|
867
|
+
def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
|
|
868
|
+
all_child_environments.append(options["env"])
|
|
869
|
+
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
870
|
+
|
|
871
|
+
with account_broker.override_subprocess_runner(runner):
|
|
872
|
+
run_job(Product.CODEX, ("codex", "exec", "task"))
|
|
873
|
+
|
|
874
|
+
assert all_child_environments[0]["CLAUDE_CODE_SESSION_ID"] == "parent-session"
|
|
@@ -190,3 +190,62 @@ def test_should_refuse_to_launch_a_bare_command_missing_from_path(monkeypatch: p
|
|
|
190
190
|
|
|
191
191
|
assert raised.value.errno == errno.ENOENT
|
|
192
192
|
assert raised.value.filename == "claude"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.parametrize("metacharacter", ["&", "|", "<", ">", "^", "%", "!", '"', "\n", "\r"])
|
|
196
|
+
def test_should_refuse_a_batch_file_argument_that_cmd_would_parse(
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch, metacharacter: str
|
|
198
|
+
) -> None:
|
|
199
|
+
resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
|
|
200
|
+
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
201
|
+
monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a batch file ran"))
|
|
202
|
+
|
|
203
|
+
with pytest.raises(OSError) as raised:
|
|
204
|
+
support.subprocess_runner(["claude", "-p", f"fix{metacharacter}test"])
|
|
205
|
+
|
|
206
|
+
assert raised.value.errno == errno.EINVAL
|
|
207
|
+
assert raised.value.filename == resolved_command
|
|
208
|
+
assert "stdin" in raised.value.strerror
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_should_refuse_metacharacters_for_an_uppercase_bat_extension(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
212
|
+
resolved_command = r"C:\tools\CLAUDE.BAT"
|
|
213
|
+
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
214
|
+
monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a batch file ran"))
|
|
215
|
+
|
|
216
|
+
with pytest.raises(OSError) as raised:
|
|
217
|
+
support.subprocess_runner(["claude", "a & b"])
|
|
218
|
+
|
|
219
|
+
assert raised.value.errno == errno.EINVAL
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_should_launch_a_batch_file_with_flags_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
223
|
+
resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
|
|
224
|
+
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
225
|
+
all_launched_argv: list[list[str]] = []
|
|
226
|
+
|
|
227
|
+
def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
|
|
228
|
+
all_launched_argv.append(list(all_argv))
|
|
229
|
+
return subprocess.CompletedProcess(all_argv, 0)
|
|
230
|
+
|
|
231
|
+
monkeypatch.setattr(support.subprocess, "run", fake_run)
|
|
232
|
+
|
|
233
|
+
support.subprocess_runner(["claude", "-p", "--output-format", "json", "--model", "opus"], input=b"a & b")
|
|
234
|
+
|
|
235
|
+
assert all_launched_argv == [[resolved_command, "-p", "--output-format", "json", "--model", "opus"]]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_should_pass_metacharacters_to_an_executable_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
239
|
+
resolved_command = "/usr/local/bin/claude"
|
|
240
|
+
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
241
|
+
all_launched_argv: list[list[str]] = []
|
|
242
|
+
|
|
243
|
+
def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
|
|
244
|
+
all_launched_argv.append(list(all_argv))
|
|
245
|
+
return subprocess.CompletedProcess(all_argv, 0)
|
|
246
|
+
|
|
247
|
+
monkeypatch.setattr(support.subprocess, "run", fake_run)
|
|
248
|
+
|
|
249
|
+
support.subprocess_runner(["claude", "-p", 'say "a & b" | 100%!'])
|
|
250
|
+
|
|
251
|
+
assert all_launched_argv == [[resolved_command, "-p", 'say "a & b" | 100%!']]
|