claude-dev-env 1.31.0 → 1.32.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.
@@ -1,8 +1,11 @@
1
- """Tests for hook_log_stop_wrapper Stop-hook wrapper that never fails."""
1
+ """Tests for hook_log_stop_wrapper -- debounced, fire-and-forget Stop hook."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import subprocess
5
7
  import sys
8
+ import time
6
9
  from pathlib import Path
7
10
 
8
11
  import pytest
@@ -16,26 +19,88 @@ from config.hook_log_extractor_constants import (
16
19
  BWS_ACCESS_TOKEN_ENV_VAR,
17
20
  BWS_EXECUTABLE_NAME,
18
21
  FLAG_INCREMENTAL,
22
+ STOP_WRAPPER_DEBOUNCE_SECONDS,
19
23
  )
20
24
 
21
25
 
22
- def test_main_returns_zero_when_bws_missing_and_extractor_fails(
26
+ def _redirect_timestamp_path(
27
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
28
+ ) -> Path:
29
+ timestamp_file = tmp_path / "stop_wrapper_last_run.txt"
30
+ monkeypatch.setattr(
31
+ hook_log_stop_wrapper,
32
+ "_last_run_timestamp_path",
33
+ lambda: timestamp_file,
34
+ )
35
+ return timestamp_file
36
+
37
+
38
+ def test_main_returns_zero_when_extractor_spawn_raises(
23
39
  monkeypatch: pytest.MonkeyPatch,
40
+ tmp_path: Path,
24
41
  ) -> None:
42
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
25
43
  monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
26
44
  monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
27
45
 
28
46
  def _raise(*_args: object, **_kwargs: object) -> None:
29
47
  raise RuntimeError("boom")
30
48
 
31
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _raise)
49
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
32
50
 
33
51
  assert hook_log_stop_wrapper.main() == 0
52
+ assert not timestamp_file.exists()
53
+
54
+
55
+ def test_main_writes_timestamp_before_spawn_to_narrow_toctou_window(
56
+ monkeypatch: pytest.MonkeyPatch,
57
+ tmp_path: Path,
58
+ ) -> None:
59
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
60
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
61
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
62
+
63
+ timestamp_file_existed_at_spawn_time: list[bool] = []
64
+
65
+ def _capture_timestamp_state(*_args: object, **_kwargs: object) -> object:
66
+ timestamp_file_existed_at_spawn_time.append(timestamp_file.exists())
67
+ return object()
68
+
69
+ monkeypatch.setattr(
70
+ hook_log_stop_wrapper.subprocess, "Popen", _capture_timestamp_state
71
+ )
72
+
73
+ hook_log_stop_wrapper.main()
74
+
75
+ assert timestamp_file_existed_at_spawn_time == [True]
76
+
77
+
78
+ def test_main_removes_timestamp_when_spawn_fails_to_allow_retry(
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ tmp_path: Path,
81
+ ) -> None:
82
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
83
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
84
+ stale_timestamp = time.time() - STOP_WRAPPER_DEBOUNCE_SECONDS - 1
85
+ timestamp_file.write_text(str(stale_timestamp))
86
+
87
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
88
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
89
+
90
+ def _raise(*_args: object, **_kwargs: object) -> None:
91
+ raise RuntimeError("boom")
92
+
93
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
94
+
95
+ assert hook_log_stop_wrapper.main() == 0
96
+ assert not timestamp_file.exists()
34
97
 
35
98
 
36
99
  def test_main_uses_bws_when_token_and_binary_present(
37
100
  monkeypatch: pytest.MonkeyPatch,
101
+ tmp_path: Path,
38
102
  ) -> None:
103
+ _redirect_timestamp_path(monkeypatch, tmp_path)
39
104
  monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
40
105
  monkeypatch.setattr(
41
106
  hook_log_stop_wrapper.shutil,
@@ -45,10 +110,11 @@ def test_main_uses_bws_when_token_and_binary_present(
45
110
 
46
111
  captured_commands: list[list[str]] = []
47
112
 
48
- def _fake_run(command_list: list[str], **_kwargs: object) -> None:
113
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
49
114
  captured_commands.append(list(command_list))
115
+ return object()
50
116
 
51
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
117
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
52
118
 
53
119
  exit_code = hook_log_stop_wrapper.main()
54
120
 
@@ -58,7 +124,11 @@ def test_main_uses_bws_when_token_and_binary_present(
58
124
  assert FLAG_INCREMENTAL in captured_commands[0]
59
125
 
60
126
 
61
- def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> None:
127
+ def test_main_skips_bws_when_token_missing(
128
+ monkeypatch: pytest.MonkeyPatch,
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ _redirect_timestamp_path(monkeypatch, tmp_path)
62
132
  monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
63
133
  monkeypatch.setattr(
64
134
  hook_log_stop_wrapper.shutil,
@@ -68,10 +138,11 @@ def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> N
68
138
 
69
139
  captured_commands: list[list[str]] = []
70
140
 
71
- def _fake_run(command_list: list[str], **_kwargs: object) -> None:
141
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
72
142
  captured_commands.append(list(command_list))
143
+ return object()
73
144
 
74
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
145
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
75
146
 
76
147
  exit_code = hook_log_stop_wrapper.main()
77
148
 
@@ -80,19 +151,195 @@ def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> N
80
151
  assert BWS_EXECUTABLE_NAME not in captured_commands[0]
81
152
 
82
153
 
83
- def test_main_skips_bws_when_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
154
+ def test_main_skips_bws_when_binary_missing(
155
+ monkeypatch: pytest.MonkeyPatch,
156
+ tmp_path: Path,
157
+ ) -> None:
158
+ _redirect_timestamp_path(monkeypatch, tmp_path)
84
159
  monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
85
160
  monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
86
161
 
87
162
  captured_commands: list[list[str]] = []
88
163
 
89
- def _fake_run(command_list: list[str], **_kwargs: object) -> None:
164
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
90
165
  captured_commands.append(list(command_list))
166
+ return object()
91
167
 
92
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
168
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
93
169
 
94
170
  exit_code = hook_log_stop_wrapper.main()
95
171
 
96
172
  assert exit_code == 0
97
173
  assert len(captured_commands) == 1
98
174
  assert BWS_EXECUTABLE_NAME not in captured_commands[0]
175
+
176
+
177
+ def test_main_skips_spawn_when_recent_timestamp_within_debounce_window(
178
+ monkeypatch: pytest.MonkeyPatch,
179
+ tmp_path: Path,
180
+ ) -> None:
181
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
182
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
183
+ timestamp_file.write_text(str(time.time()))
184
+
185
+ captured_commands: list[list[str]] = []
186
+
187
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
188
+ captured_commands.append(list(command_list))
189
+ return object()
190
+
191
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
192
+
193
+ exit_code = hook_log_stop_wrapper.main()
194
+
195
+ assert exit_code == 0
196
+ assert len(captured_commands) == 0
197
+
198
+
199
+ def test_main_spawns_when_timestamp_older_than_debounce_window(
200
+ monkeypatch: pytest.MonkeyPatch,
201
+ tmp_path: Path,
202
+ ) -> None:
203
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
204
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
205
+ stale_timestamp = time.time() - STOP_WRAPPER_DEBOUNCE_SECONDS - 1
206
+ timestamp_file.write_text(str(stale_timestamp))
207
+
208
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
209
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
210
+
211
+ captured_commands: list[list[str]] = []
212
+
213
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
214
+ captured_commands.append(list(command_list))
215
+ return object()
216
+
217
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
218
+
219
+ exit_code = hook_log_stop_wrapper.main()
220
+
221
+ assert exit_code == 0
222
+ assert len(captured_commands) == 1
223
+
224
+
225
+ def test_main_writes_current_timestamp_to_file(
226
+ monkeypatch: pytest.MonkeyPatch,
227
+ tmp_path: Path,
228
+ ) -> None:
229
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
230
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
231
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
232
+ monkeypatch.setattr(
233
+ hook_log_stop_wrapper.subprocess,
234
+ "Popen",
235
+ lambda *_args, **_kwargs: object(),
236
+ )
237
+
238
+ timestamp_before_call = time.time()
239
+ hook_log_stop_wrapper.main()
240
+ timestamp_after_call = time.time()
241
+
242
+ assert timestamp_file.exists()
243
+ written_timestamp = float(timestamp_file.read_text().strip())
244
+ assert timestamp_before_call <= written_timestamp <= timestamp_after_call
245
+
246
+
247
+ def test_main_passes_devnull_streams_to_detached_spawn(
248
+ monkeypatch: pytest.MonkeyPatch,
249
+ tmp_path: Path,
250
+ ) -> None:
251
+ _redirect_timestamp_path(monkeypatch, tmp_path)
252
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
253
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
254
+
255
+ captured_kwargs: dict[str, object] = {}
256
+
257
+ def _fake_popen(command_list: list[str], **kwargs: object) -> object:
258
+ captured_kwargs.update(kwargs)
259
+ return object()
260
+
261
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
262
+
263
+ hook_log_stop_wrapper.main()
264
+
265
+ assert captured_kwargs.get("stdin") is hook_log_stop_wrapper.subprocess.DEVNULL
266
+ assert captured_kwargs.get("stdout") is hook_log_stop_wrapper.subprocess.DEVNULL
267
+ assert captured_kwargs.get("stderr") is hook_log_stop_wrapper.subprocess.DEVNULL
268
+
269
+
270
+ def test_main_recovers_when_timestamp_file_is_corrupted(
271
+ monkeypatch: pytest.MonkeyPatch,
272
+ tmp_path: Path,
273
+ ) -> None:
274
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
275
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
276
+ timestamp_file.write_text("not-a-float")
277
+
278
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
279
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
280
+
281
+ captured_commands: list[list[str]] = []
282
+
283
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
284
+ captured_commands.append(list(command_list))
285
+ return object()
286
+
287
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
288
+
289
+ exit_code = hook_log_stop_wrapper.main()
290
+
291
+ assert exit_code == 0
292
+ assert len(captured_commands) == 1
293
+
294
+
295
+ def test_main_treats_future_timestamp_as_not_debounced(
296
+ monkeypatch: pytest.MonkeyPatch,
297
+ tmp_path: Path,
298
+ ) -> None:
299
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
300
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
301
+ far_future_timestamp = time.time() + 3600
302
+ timestamp_file.write_text(str(far_future_timestamp))
303
+
304
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
305
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
306
+
307
+ captured_commands: list[list[str]] = []
308
+
309
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
310
+ captured_commands.append(list(command_list))
311
+ return object()
312
+
313
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
314
+
315
+ exit_code = hook_log_stop_wrapper.main()
316
+
317
+ assert exit_code == 0
318
+ assert len(captured_commands) == 1
319
+
320
+
321
+ def test_main_passes_hidden_startupinfo_on_windows(
322
+ monkeypatch: pytest.MonkeyPatch,
323
+ tmp_path: Path,
324
+ ) -> None:
325
+ _redirect_timestamp_path(monkeypatch, tmp_path)
326
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
327
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
328
+
329
+ captured_kwargs: dict[str, object] = {}
330
+
331
+ def _fake_popen(command_list: list[str], **kwargs: object) -> object:
332
+ captured_kwargs.update(kwargs)
333
+ return object()
334
+
335
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
336
+
337
+ hook_log_stop_wrapper.main()
338
+
339
+ if os.name == "nt":
340
+ startup_info = captured_kwargs.get("startupinfo")
341
+ assert startup_info is not None
342
+ assert startup_info.dwFlags & subprocess.STARTF_USESHOWWINDOW
343
+ assert startup_info.wShowWindow == subprocess.SW_HIDE
344
+ else:
345
+ assert captured_kwargs.get("start_new_session") is True
package/hooks/hooks.json CHANGED
@@ -39,6 +39,11 @@
39
39
  "type": "command",
40
40
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/tdd_enforcer.py",
41
41
  "timeout": 10
42
+ },
43
+ {
44
+ "type": "command",
45
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
46
+ "timeout": 10
42
47
  }
43
48
  ]
44
49
  },
@@ -89,6 +94,11 @@
89
94
  "type": "command",
90
95
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
91
96
  "timeout": 10
97
+ },
98
+ {
99
+ "type": "command",
100
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
101
+ "timeout": 10
92
102
  }
93
103
  ]
94
104
  },
@@ -112,6 +122,11 @@
112
122
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_data_dir_cleanup.py",
113
123
  "timeout": 10
114
124
  },
125
+ {
126
+ "type": "command",
127
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py",
128
+ "timeout": 10
129
+ },
115
130
  {
116
131
  "type": "command",
117
132
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py",
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — clean the Claude Code session-env directory on Windows.
3
+
4
+ Claude Code's Bash tool sets up a per-session sandbox at
5
+ ``~/.claude/session-env/<session_id>/``. The mkdir call appears non-recursive,
6
+ so once the directory exists, later Bash invocations in the same session can
7
+ throw ``EEXIST`` and abort. PowerShell tool calls are unaffected.
8
+
9
+ This hook removes the current session's pre-existing directory at start and
10
+ prunes sibling entries whose mtime is older than the stale-age threshold so
11
+ the parent directory does not grow without bound.
12
+
13
+ Tracking: https://github.com/anthropics/claude-code/issues — Windows-only
14
+ mkdir bug separate from the EEXIST fixes in v2.1.70-v2.1.72.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import stat
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Callable
27
+
28
+
29
+ def _insert_hooks_tree_for_imports() -> None:
30
+ hooks_tree = Path(__file__).resolve().parent.parent
31
+ hooks_tree_string = str(hooks_tree)
32
+ if hooks_tree_string not in sys.path:
33
+ sys.path.insert(0, hooks_tree_string)
34
+
35
+
36
+ _insert_hooks_tree_for_imports()
37
+
38
+ from config.session_env_cleanup_constants import (
39
+ RMTREE_ONEXC_PYTHON_VERSION,
40
+ SESSION_ENV_DIRECTORY,
41
+ SESSION_ID_PATTERN,
42
+ STALE_AGE_SECONDS,
43
+ WINDOWS_PLATFORM_TAG,
44
+ )
45
+
46
+
47
+ def _strip_read_only_and_retry(
48
+ removal_function: Callable[[str], None],
49
+ target_path: str,
50
+ *_unused_exception_info: object,
51
+ ) -> None:
52
+ try:
53
+ os.chmod(target_path, stat.S_IWRITE)
54
+ removal_function(target_path)
55
+ except OSError:
56
+ pass
57
+
58
+
59
+ def _force_rmtree(target_path: str) -> None:
60
+ rmtree_onexc_python_version = RMTREE_ONEXC_PYTHON_VERSION
61
+ try:
62
+ if sys.version_info >= rmtree_onexc_python_version:
63
+ shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
64
+ else:
65
+ shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)
66
+ except OSError:
67
+ pass
68
+
69
+
70
+ def prune_session_env(
71
+ session_env_directory: str,
72
+ session_id: str,
73
+ stale_age_seconds: float,
74
+ ) -> None:
75
+ """Remove the current session's directory and prune stale siblings."""
76
+ if session_id:
77
+ current_session_path = os.path.join(session_env_directory, session_id)
78
+ if os.path.isdir(current_session_path):
79
+ _force_rmtree(current_session_path)
80
+ if not os.path.isdir(session_env_directory):
81
+ return
82
+ stale_cutoff_seconds = time.time() - stale_age_seconds
83
+ try:
84
+ all_entry_names = os.listdir(session_env_directory)
85
+ except OSError:
86
+ return
87
+ for each_entry_name in all_entry_names:
88
+ entry_path = os.path.join(session_env_directory, each_entry_name)
89
+ try:
90
+ entry_mtime_seconds = os.path.getmtime(entry_path)
91
+ except OSError:
92
+ continue
93
+ if entry_mtime_seconds >= stale_cutoff_seconds:
94
+ continue
95
+ _force_rmtree(entry_path)
96
+
97
+
98
+ def _read_session_id_from_stdin() -> str:
99
+ session_id_pattern = SESSION_ID_PATTERN
100
+ try:
101
+ payload = json.load(sys.stdin)
102
+ except (json.JSONDecodeError, ValueError):
103
+ return ""
104
+ if not isinstance(payload, dict):
105
+ return ""
106
+ raw_session_id = payload.get("session_id")
107
+ if not isinstance(raw_session_id, str):
108
+ return ""
109
+ if not session_id_pattern.match(raw_session_id):
110
+ return ""
111
+ return raw_session_id
112
+
113
+
114
+ def main() -> None:
115
+ windows_platform_tag = WINDOWS_PLATFORM_TAG
116
+ if sys.platform != windows_platform_tag:
117
+ return
118
+ session_env_directory = SESSION_ENV_DIRECTORY
119
+ stale_age_seconds = STALE_AGE_SECONDS
120
+ session_id = _read_session_id_from_stdin()
121
+ prune_session_env(
122
+ session_env_directory=session_env_directory,
123
+ session_id=session_id,
124
+ stale_age_seconds=stale_age_seconds,
125
+ )
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()