claude-dev-env 8.54.4 → 8.54.6
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/hooks/blocking/session_title_stop_gate.py +15 -2
- package/hooks/blocking/test_session_title_stop_gate.py +37 -0
- package/hooks/hooks_constants/session_title_constants.py +1 -0
- package/package.json +1 -1
- package/scripts/conftest.py +23 -0
- package/scripts/test_broker_state_isolation.py +48 -0
- package/scripts/test_resolve_worker_spawn.py +0 -1
|
@@ -11,7 +11,8 @@ The hook stays silent when:
|
|
|
11
11
|
- the stop is already a retry after a block (stop_hook_active is true);
|
|
12
12
|
- the session shows no title tool: no title tool named anywhere in the
|
|
13
13
|
transcript, and either no remote session id in the environment or a
|
|
14
|
-
deferred tool listing that leaves the title tool out
|
|
14
|
+
deferred tool listing that leaves the title tool out, or an earlier
|
|
15
|
+
Stop block the session never met;
|
|
15
16
|
- the transcript cannot be read.
|
|
16
17
|
"""
|
|
17
18
|
|
|
@@ -32,6 +33,7 @@ from hooks_constants.session_title_constants import (
|
|
|
32
33
|
ALLOW_EXIT_CODE,
|
|
33
34
|
BLOCK_DECISION,
|
|
34
35
|
DEFERRED_TOOL_ATTACHMENT_TYPES,
|
|
36
|
+
HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE,
|
|
35
37
|
REMOTE_SESSION_ENVIRONMENT_VARIABLE,
|
|
36
38
|
REMOTE_TITLE_TOOL_NAME,
|
|
37
39
|
STOP_BLOCK_REASON,
|
|
@@ -85,6 +87,15 @@ def _lists_deferred_tools(all_entry_fields: dict[str, object]) -> bool:
|
|
|
85
87
|
return isinstance(attachment, dict) and attachment.get("type") in DEFERRED_TOOL_ATTACHMENT_TYPES
|
|
86
88
|
|
|
87
89
|
|
|
90
|
+
def _is_stop_block(all_entry_fields: dict[str, object]) -> bool:
|
|
91
|
+
attachment = all_entry_fields.get("attachment")
|
|
92
|
+
return (
|
|
93
|
+
isinstance(attachment, dict)
|
|
94
|
+
and attachment.get("type") == HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE
|
|
95
|
+
and attachment.get("hookEvent") == STOP_GATE_EVENT_NAME
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
88
99
|
def _is_title_call(entry_type: object, all_block_fields: dict[str, object]) -> bool:
|
|
89
100
|
return (
|
|
90
101
|
entry_type == "assistant"
|
|
@@ -132,6 +143,7 @@ def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: b
|
|
|
132
143
|
prompt -> work, no title call => block reason
|
|
133
144
|
no title tool anywhere, not remote => None
|
|
134
145
|
remote, tool list lacks the title tool => None
|
|
146
|
+
remote, earlier Stop block never met => None
|
|
135
147
|
|
|
136
148
|
Args:
|
|
137
149
|
all_entries: The transcript entries, oldest first.
|
|
@@ -144,7 +156,8 @@ def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: b
|
|
|
144
156
|
each_name for each_entry in all_entries for each_name in _title_tool_names_in(each_entry)
|
|
145
157
|
]
|
|
146
158
|
if not all_seen_tool_names and (
|
|
147
|
-
not is_remote_session
|
|
159
|
+
not is_remote_session
|
|
160
|
+
or any(_lists_deferred_tools(each) or _is_stop_block(each) for each in all_entries)
|
|
148
161
|
):
|
|
149
162
|
return None
|
|
150
163
|
if _title_set_since(all_entries[_turn_start_index(all_entries) :]):
|
|
@@ -199,6 +199,43 @@ def test_should_block_a_remote_session_whose_tool_list_names_the_title_tool(
|
|
|
199
199
|
assert json.loads(stdout_text)["decision"] == "block"
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
def earlier_stop_block() -> dict[str, object]:
|
|
203
|
+
return {
|
|
204
|
+
"type": "attachment",
|
|
205
|
+
"attachment": {"type": "hook_blocking_error", "hookEvent": "Stop"},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_should_stay_silent_in_a_remote_session_after_an_unmet_earlier_block(
|
|
210
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
211
|
+
) -> None:
|
|
212
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
213
|
+
all_entries = [
|
|
214
|
+
prompt("Fix the runner"),
|
|
215
|
+
earlier_stop_block(),
|
|
216
|
+
prompt("Next task"),
|
|
217
|
+
tool_call("t1", "Bash"),
|
|
218
|
+
tool_result("t1"),
|
|
219
|
+
]
|
|
220
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_should_block_after_an_earlier_block_once_the_title_tool_is_listed(
|
|
224
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
225
|
+
) -> None:
|
|
226
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
227
|
+
all_entries = [
|
|
228
|
+
deferred_tools(REMOTE_TITLE_TOOL),
|
|
229
|
+
prompt("Fix the runner"),
|
|
230
|
+
earlier_stop_block(),
|
|
231
|
+
prompt("Next task"),
|
|
232
|
+
tool_call("t1", "Bash"),
|
|
233
|
+
tool_result("t1"),
|
|
234
|
+
]
|
|
235
|
+
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
236
|
+
assert json.loads(stdout_text)["decision"] == "block"
|
|
237
|
+
|
|
238
|
+
|
|
202
239
|
def test_should_stay_silent_on_the_retry_after_a_block(
|
|
203
240
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
204
241
|
) -> None:
|
|
@@ -15,6 +15,7 @@ ALLOW_EXIT_CODE = 0
|
|
|
15
15
|
BLOCK_EXIT_CODE = 2
|
|
16
16
|
BLOCK_DECISION = "block"
|
|
17
17
|
DEFERRED_TOOL_ATTACHMENT_TYPES = frozenset({"deferred_tools_delta", "deferred_tools_record"})
|
|
18
|
+
HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE = "hook_blocking_error"
|
|
18
19
|
|
|
19
20
|
STATUS_PREFIX_PATTERN = re.compile("^(\U0001f6a9|\u2705|\u23f3)\ufe0f? (?=\\S)")
|
|
20
21
|
MAXIMUM_NAME_LENGTH = 25
|
package/package.json
CHANGED
package/scripts/conftest.py
CHANGED
|
@@ -15,9 +15,32 @@ is in place by the time any test module runs its top-level imports.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import importlib
|
|
18
19
|
import sys
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
21
24
|
_scripts_directory = str(Path(__file__).resolve().parent)
|
|
22
25
|
if _scripts_directory not in sys.path:
|
|
23
26
|
sys.path.insert(0, _scripts_directory)
|
|
27
|
+
|
|
28
|
+
_live_broker_state_resolver = importlib.import_module("account_broker_support").broker_state_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def isolated_broker_state_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
33
|
+
"""Point every module's ``broker_state_path`` at a per-test file.
|
|
34
|
+
|
|
35
|
+
``account_broker`` binds ``broker_state_path`` by name from
|
|
36
|
+
``account_broker_support``, so each bound copy is replaced. A test that
|
|
37
|
+
patches only one copy still keeps the other off the home directory.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The isolated broker state path for this test.
|
|
41
|
+
"""
|
|
42
|
+
isolated_path = tmp_path / "account-broker" / "state.json"
|
|
43
|
+
for each_module in list(sys.modules.values()):
|
|
44
|
+
if getattr(each_module, "broker_state_path", None) is _live_broker_state_resolver:
|
|
45
|
+
monkeypatch.setattr(each_module, "broker_state_path", lambda: isolated_path)
|
|
46
|
+
return isolated_path
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Broker state written during a test stays out of the home directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import account_broker
|
|
12
|
+
from dev_env_scripts_constants.account_broker_constants import (
|
|
13
|
+
Account,
|
|
14
|
+
Meters,
|
|
15
|
+
Product,
|
|
16
|
+
ProductAdapter,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_meter_cache_write_lands_outside_the_home_directory(
|
|
21
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
22
|
+
tmp_path: Path,
|
|
23
|
+
isolated_broker_state_path: Path,
|
|
24
|
+
) -> None:
|
|
25
|
+
fake_home = tmp_path / "home"
|
|
26
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
27
|
+
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
|
28
|
+
account = Account(Product.CLAUDE, "only", tmp_path / "only")
|
|
29
|
+
reset_at = datetime(2026, 10, 5, tzinfo=timezone.utc)
|
|
30
|
+
adapter = ProductAdapter(
|
|
31
|
+
lambda: (account,),
|
|
32
|
+
lambda each_account: Meters(80, reset_at, 80, reset_at),
|
|
33
|
+
"CLAUDE_CONFIG_DIR",
|
|
34
|
+
("usage limit",),
|
|
35
|
+
)
|
|
36
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, adapter)
|
|
37
|
+
|
|
38
|
+
def runner(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
40
|
+
|
|
41
|
+
with account_broker.override_subprocess_runner(runner):
|
|
42
|
+
account_broker.run_job(Product.CLAUDE, ["claude"])
|
|
43
|
+
|
|
44
|
+
home_state_path = fake_home / ".claude" / "account-broker" / "state.json"
|
|
45
|
+
assert not home_state_path.exists()
|
|
46
|
+
assert f"claude:only:{account.home}" in isolated_broker_state_path.read_text(
|
|
47
|
+
encoding="utf-8"
|
|
48
|
+
)
|
|
@@ -1129,7 +1129,6 @@ def test_usage_limit_fallover_delivers_full_prompt_to_each_account(
|
|
|
1129
1129
|
return subprocess.CompletedProcess(argv, 0, FIXTURE_CLAUDE_STDOUT, "")
|
|
1130
1130
|
|
|
1131
1131
|
monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, adapter)
|
|
1132
|
-
monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "state.json")
|
|
1133
1132
|
monkeypatch.setattr(
|
|
1134
1133
|
dispatcher,
|
|
1135
1134
|
"spawn_preflight_runner",
|