ai-push-hooks 0.2.1 → 0.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.
@@ -0,0 +1,403 @@
1
+ """Common shell-free child-process execution for runner adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ import signal
8
+ import stat
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass
14
+
15
+ from ...paths import path_is_link_or_reparse
16
+ from .contracts import (
17
+ RunnerError,
18
+ RunnerExecutableNotFoundError,
19
+ RunnerSignalError,
20
+ RunnerTimeoutError,
21
+ bounded_diagnostic,
22
+ )
23
+
24
+
25
+ PROCESS_CHUNK_BYTES = 64 * 1024
26
+ # Agent CLIs commonly emit multi-megabyte JSONL event streams. This remains a
27
+ # hard per-stream bound; callers with a tighter budget can override it.
28
+ DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024
29
+ PROCESS_CLEANUP_GRACE_SECONDS = 1.0
30
+
31
+
32
+ @dataclass(frozen=True, repr=False)
33
+ class ProcessResult:
34
+ """Bounded child output. Streams are never included in ``repr``."""
35
+
36
+ returncode: int
37
+ stdout: str
38
+ stderr: str
39
+ stdout_truncated: bool = False
40
+ stderr_truncated: bool = False
41
+ # Keep the original bounded bytes for callers that persist process output.
42
+ # The text fields above intentionally retain the runner's surrogateescape
43
+ # decoding contract.
44
+ stdout_bytes: bytes = b""
45
+ stderr_bytes: bytes = b""
46
+
47
+ def __repr__(self) -> str:
48
+ return (
49
+ "ProcessResult("
50
+ f"returncode={self.returncode!r}, stdout=<redacted>, stderr=<redacted>, "
51
+ f"stdout_truncated={self.stdout_truncated!r}, stderr_truncated={self.stderr_truncated!r})"
52
+ )
53
+
54
+
55
+ def _read_bounded(
56
+ stream: object,
57
+ limit: int,
58
+ output: bytearray,
59
+ truncated: list[bool],
60
+ output_lock: threading.Lock,
61
+ ) -> None:
62
+ read = getattr(stream, "read")
63
+ try:
64
+ while True:
65
+ try:
66
+ chunk = read(PROCESS_CHUNK_BYTES)
67
+ except (OSError, ValueError):
68
+ return
69
+ if not chunk:
70
+ return
71
+ with output_lock:
72
+ remaining = limit - len(output)
73
+ if remaining > 0:
74
+ output.extend(chunk[:remaining])
75
+ if len(chunk) > remaining:
76
+ truncated[0] = True
77
+ finally:
78
+ _close_pipe(stream)
79
+
80
+
81
+ def _signal_process_group(process_group_id: int, signum: int) -> None:
82
+ try:
83
+ os.killpg(process_group_id, signum)
84
+ except (OSError, ProcessLookupError):
85
+ return
86
+
87
+
88
+ def _process_group_exists(process_group_id: int) -> bool:
89
+ try:
90
+ os.killpg(process_group_id, 0)
91
+ except (OSError, ProcessLookupError):
92
+ return False
93
+ return True
94
+
95
+
96
+ def _wait_process_until(process: subprocess.Popen[bytes], deadline: float) -> None:
97
+ while process.poll() is None:
98
+ remaining = deadline - time.monotonic()
99
+ if remaining <= 0:
100
+ return
101
+ try:
102
+ process.wait(timeout=min(remaining, 0.05))
103
+ except subprocess.TimeoutExpired:
104
+ continue
105
+
106
+
107
+ def _stop_process(process: subprocess.Popen[bytes], deadline: float) -> None:
108
+ """Terminate this invocation's process group within one shared deadline."""
109
+
110
+ process_group_id = process.pid if os.name == "posix" else None
111
+ if process_group_id is not None:
112
+ # start_new_session=True makes the leader's pid the private process
113
+ # group id. Do not gate this on leader.poll(): descendants can retain
114
+ # the pipes after the leader has already exited.
115
+ _signal_process_group(process_group_id, signal.SIGTERM)
116
+ elif process.poll() is None:
117
+ process.terminate()
118
+
119
+ _wait_process_until(process, deadline)
120
+
121
+ if process_group_id is not None:
122
+ while _process_group_exists(process_group_id):
123
+ remaining = deadline - time.monotonic()
124
+ if remaining <= 0:
125
+ _signal_process_group(process_group_id, signal.SIGKILL)
126
+ try:
127
+ process.wait(timeout=0.1)
128
+ except subprocess.TimeoutExpired:
129
+ pass
130
+ break
131
+ time.sleep(min(0.01, remaining))
132
+ elif process.poll() is None:
133
+ remaining = deadline - time.monotonic()
134
+ if remaining > 0:
135
+ try:
136
+ process.wait(timeout=remaining)
137
+ except subprocess.TimeoutExpired:
138
+ process.kill()
139
+ try:
140
+ process.wait(timeout=0.1)
141
+ except subprocess.TimeoutExpired:
142
+ pass
143
+
144
+
145
+ def _close_pipe(stream: object) -> None:
146
+ """Close an unbuffered pipe through its owning stream object."""
147
+
148
+ try:
149
+ getattr(stream, "close")()
150
+ except (OSError, ValueError, AttributeError):
151
+ return
152
+
153
+
154
+ def _finish_capture(
155
+ streams: tuple[object, ...],
156
+ threads: tuple[threading.Thread, ...],
157
+ deadline: float,
158
+ ) -> None:
159
+ """Bound all joins and force-close pipe fds if a reader remains blocked."""
160
+
161
+ for thread in threads:
162
+ remaining = deadline - time.monotonic()
163
+ if remaining <= 0:
164
+ break
165
+ thread.join(timeout=remaining)
166
+ if any(thread.is_alive() for thread in threads):
167
+ for stream in streams:
168
+ _close_pipe(stream)
169
+ for thread in threads:
170
+ remaining = deadline - time.monotonic()
171
+ if remaining <= 0:
172
+ break
173
+ thread.join(timeout=remaining)
174
+ # Popen creates FileIO objects because bufsize=0. FileIO.close() does not
175
+ # flush buffered data and atomically relinquishes descriptor ownership, so
176
+ # repeated cleanup cannot later close a descriptor reused by another child.
177
+ for stream in streams:
178
+ _close_pipe(stream)
179
+
180
+
181
+ def _captured_result(
182
+ returncode: int,
183
+ stdout: bytearray,
184
+ stderr: bytearray,
185
+ stdout_truncated: list[bool],
186
+ stderr_truncated: list[bool],
187
+ stdout_lock: threading.Lock,
188
+ stderr_lock: threading.Lock,
189
+ ) -> ProcessResult:
190
+ """Snapshot bounded streams for an exception without exposing them in it."""
191
+
192
+ with stdout_lock:
193
+ stdout_text = bytes(stdout).decode("utf-8", errors="surrogateescape")
194
+ with stderr_lock:
195
+ stderr_text = bytes(stderr).decode("utf-8", errors="surrogateescape")
196
+ return ProcessResult(
197
+ returncode=returncode,
198
+ stdout=stdout_text,
199
+ stderr=stderr_text,
200
+ stdout_truncated=stdout_truncated[0],
201
+ stderr_truncated=stderr_truncated[0],
202
+ stdout_bytes=bytes(stdout),
203
+ stderr_bytes=bytes(stderr),
204
+ )
205
+
206
+
207
+ def run_process(
208
+ argv: Sequence[str],
209
+ *,
210
+ cwd: pathlib.Path,
211
+ input_text: str | None = None,
212
+ input_path: pathlib.Path | None = None,
213
+ timeout_seconds: float,
214
+ env: Mapping[str, str] | None = None,
215
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
216
+ ) -> ProcessResult:
217
+ """Run an argv vector directly, with bounded concurrent stream capture.
218
+
219
+ ``shell=False`` is explicit and prompt text is written only to stdin. The
220
+ environment is accepted for the child but is never inspected or included
221
+ in errors. Non-zero exits are returned for adapter-specific normalization;
222
+ timeout and signal termination are raised as distinct fail-closed errors.
223
+ """
224
+
225
+ if not isinstance(argv, (tuple, list)) or not argv or any(
226
+ not isinstance(argument, str) or not argument or "\x00" in argument for argument in argv
227
+ ):
228
+ raise RunnerError("runner command must be a non-empty NUL-free argv vector")
229
+ if not isinstance(cwd, pathlib.Path):
230
+ cwd = pathlib.Path(cwd)
231
+ if not cwd.is_dir():
232
+ raise RunnerError("runner cwd must be an existing directory")
233
+ if timeout_seconds <= 0:
234
+ raise RunnerError("runner timeout must be greater than zero")
235
+ if max_output_bytes < 0:
236
+ raise RunnerError("runner output bound must not be negative")
237
+ if input_text is not None and input_path is not None:
238
+ raise RunnerError("runner input_text and input_path are mutually exclusive")
239
+ input_file = None
240
+ if input_path is not None:
241
+ if not isinstance(input_path, pathlib.Path):
242
+ input_path = pathlib.Path(input_path)
243
+ if path_is_link_or_reparse(input_path):
244
+ raise RunnerError("runner input file must not be a symlink or reparse point")
245
+ descriptor = -1
246
+ try:
247
+ # O_NONBLOCK prevents opening a FIFO from waiting for a writer;
248
+ # O_NOFOLLOW closes the symlink race between validation and open.
249
+ flags = (
250
+ os.O_RDONLY
251
+ | os.O_NONBLOCK
252
+ | getattr(os, "O_CLOEXEC", 0)
253
+ | getattr(os, "O_NOFOLLOW", 0)
254
+ )
255
+ descriptor = os.open(input_path, flags)
256
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
257
+ raise RunnerError("runner input file must be an ordinary regular file")
258
+ input_file = os.fdopen(descriptor, "rb", buffering=0)
259
+ descriptor = -1
260
+ except RunnerError:
261
+ raise
262
+ except (OSError, ValueError) as exc:
263
+ raise RunnerError("runner input file could not be opened safely") from exc
264
+ finally:
265
+ if descriptor >= 0:
266
+ os.close(descriptor)
267
+
268
+ popen_kwargs: dict[str, object] = {
269
+ "cwd": cwd,
270
+ "stdin": subprocess.PIPE,
271
+ "stdout": subprocess.PIPE,
272
+ "stderr": subprocess.PIPE,
273
+ "shell": False,
274
+ "env": dict(env) if env is not None else None,
275
+ # Buffered pipe wrappers cannot safely be bypassed with os.close(): the
276
+ # wrapper still owns the descriptor and may close a later reused fd in
277
+ # its destructor. Unbuffered FileIO makes close bounded and ownership
278
+ # explicit while the reader threads retain concurrent stream draining.
279
+ "bufsize": 0,
280
+ }
281
+ if os.name == "posix":
282
+ popen_kwargs["start_new_session"] = True
283
+ try:
284
+ process = subprocess.Popen(list(argv), **popen_kwargs)
285
+ except FileNotFoundError as exc:
286
+ if input_file is not None:
287
+ input_file.close()
288
+ raise RunnerExecutableNotFoundError(
289
+ "runner executable was not found",
290
+ details=bounded_diagnostic(argv[0]),
291
+ ) from exc
292
+ except OSError as exc:
293
+ if input_file is not None:
294
+ input_file.close()
295
+ raise RunnerError("runner process could not be started", details=type(exc).__name__) from exc
296
+
297
+ if process.stdout is None or process.stderr is None or process.stdin is None:
298
+ _stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
299
+ if input_file is not None:
300
+ input_file.close()
301
+ raise RunnerError("runner process pipes were not available")
302
+
303
+ stdout = bytearray()
304
+ stderr = bytearray()
305
+ stdout_lock = threading.Lock()
306
+ stderr_lock = threading.Lock()
307
+ stdout_truncated = [False]
308
+ stderr_truncated = [False]
309
+ stdout_thread = threading.Thread(
310
+ target=_read_bounded,
311
+ args=(process.stdout, max_output_bytes, stdout, stdout_truncated, stdout_lock),
312
+ daemon=True,
313
+ )
314
+ stderr_thread = threading.Thread(
315
+ target=_read_bounded,
316
+ args=(process.stderr, max_output_bytes, stderr, stderr_truncated, stderr_lock),
317
+ daemon=True,
318
+ )
319
+ stdout_thread.start()
320
+ stderr_thread.start()
321
+
322
+ input_bytes = None if input_text is None else input_text.encode("utf-8", errors="surrogateescape")
323
+
324
+ def write_input() -> None:
325
+ try:
326
+ if input_file is not None:
327
+ while True:
328
+ chunk = input_file.read(PROCESS_CHUNK_BYTES)
329
+ if not chunk:
330
+ break
331
+ offset = 0
332
+ while offset < len(chunk):
333
+ written = process.stdin.write(chunk[offset:])
334
+ if not written:
335
+ return
336
+ offset += written
337
+ elif input_bytes:
338
+ offset = 0
339
+ while offset < len(input_bytes):
340
+ written = process.stdin.write(input_bytes[offset:])
341
+ if not written:
342
+ break
343
+ offset += written
344
+ except (BrokenPipeError, OSError, ValueError):
345
+ pass
346
+ finally:
347
+ if input_file is not None:
348
+ _close_pipe(input_file)
349
+ _close_pipe(process.stdin)
350
+
351
+ input_thread = threading.Thread(target=write_input, daemon=True)
352
+ input_thread.start()
353
+ returncode: int | None = None
354
+ timeout_cause: subprocess.TimeoutExpired | None = None
355
+ try:
356
+ returncode = process.wait(timeout=timeout_seconds)
357
+ except subprocess.TimeoutExpired as exc:
358
+ timeout_cause = exc
359
+ finally:
360
+ cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
361
+ _stop_process(process, cleanup_deadline)
362
+ _finish_capture(
363
+ (process.stdin, process.stdout, process.stderr),
364
+ (input_thread, stdout_thread, stderr_thread),
365
+ cleanup_deadline,
366
+ )
367
+
368
+ if returncode is None:
369
+ returncode = process.poll()
370
+ if timeout_cause is not None:
371
+ timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
372
+ process_result = _captured_result(
373
+ timeout_returncode,
374
+ stdout,
375
+ stderr,
376
+ stdout_truncated,
377
+ stderr_truncated,
378
+ stdout_lock,
379
+ stderr_lock,
380
+ )
381
+ error = RunnerTimeoutError("runner process exceeded its timeout")
382
+ setattr(error, "_process_result", process_result)
383
+ raise error from timeout_cause
384
+
385
+ if returncode is None: # pragma: no cover - process.wait either returns or raises
386
+ raise RunnerError("runner process returned no exit status")
387
+ process_result = _captured_result(
388
+ returncode,
389
+ stdout,
390
+ stderr,
391
+ stdout_truncated,
392
+ stderr_truncated,
393
+ stdout_lock,
394
+ stderr_lock,
395
+ )
396
+ if returncode < 0:
397
+ error = RunnerSignalError(
398
+ "runner process terminated by signal",
399
+ details=str(-returncode),
400
+ )
401
+ setattr(error, "_process_result", process_result)
402
+ raise error
403
+ return process_result
@@ -0,0 +1,104 @@
1
+ """Static, lazy registry for the four built-in runner adapter types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from dataclasses import dataclass
7
+ from types import ModuleType
8
+ from typing import Callable, Mapping
9
+
10
+ from .contracts import (
11
+ Runner,
12
+ RunnerAdapterUnavailableError,
13
+ RunnerContractError,
14
+ )
15
+
16
+
17
+ KNOWN_RUNNER_TYPES = ("opencode", "codex", "claude", "command")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class LazyRunnerSpec:
22
+ """Import and construct an adapter only when that type is selected."""
23
+
24
+ module: str
25
+ factory_name: str = "create_runner"
26
+
27
+ def load(self) -> Runner:
28
+ try:
29
+ module: ModuleType = importlib.import_module(self.module)
30
+ except ModuleNotFoundError as exc:
31
+ if exc.name == self.module:
32
+ raise RunnerAdapterUnavailableError(
33
+ "selected runner adapter is not installed in this build"
34
+ ) from exc
35
+ raise RunnerAdapterUnavailableError(
36
+ "selected runner adapter could not load its dependency",
37
+ details=type(exc).__name__,
38
+ ) from exc
39
+ try:
40
+ factory: Callable[[], Runner] = getattr(module, self.factory_name)
41
+ except AttributeError as exc:
42
+ raise RunnerAdapterUnavailableError(
43
+ "selected runner adapter has no factory"
44
+ ) from exc
45
+ try:
46
+ runner = factory()
47
+ except RunnerAdapterUnavailableError:
48
+ raise
49
+ except Exception as exc: # noqa: BLE001
50
+ raise RunnerAdapterUnavailableError(
51
+ "selected runner adapter could not be constructed",
52
+ details=type(exc).__name__,
53
+ ) from exc
54
+ if not callable(getattr(runner, "run", None)):
55
+ raise RunnerAdapterUnavailableError("selected runner does not implement run(request)")
56
+ return runner
57
+
58
+
59
+ _DEFAULT_SPECS = {
60
+ "opencode": LazyRunnerSpec("ai_push_hooks.executors.runners.opencode"),
61
+ "codex": LazyRunnerSpec("ai_push_hooks.executors.runners.codex"),
62
+ "claude": LazyRunnerSpec("ai_push_hooks.executors.runners.claude"),
63
+ "command": LazyRunnerSpec("ai_push_hooks.executors.runners.command"),
64
+ }
65
+
66
+
67
+ class RunnerRegistry:
68
+ """A fixed registry; it intentionally has no runtime plugin registration API."""
69
+
70
+ def __init__(self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None) -> None:
71
+ selected = _DEFAULT_SPECS if specs is None else dict(specs)
72
+ unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
73
+ if unknown:
74
+ raise RunnerContractError(
75
+ "runner registry contains unsupported adapter types: " + ", ".join(sorted(unknown))
76
+ )
77
+ if set(selected) != set(KNOWN_RUNNER_TYPES):
78
+ raise RunnerContractError("runner registry must contain all four known adapter types")
79
+ self._specs = dict(selected)
80
+ self._loaded: dict[str, Runner] = {}
81
+
82
+ @property
83
+ def known_types(self) -> tuple[str, ...]:
84
+ return KNOWN_RUNNER_TYPES
85
+
86
+ def get(self, runner_type: str) -> Runner:
87
+ if runner_type not in self._specs:
88
+ raise RunnerContractError(f"unknown runner type: {runner_type!r}")
89
+ if runner_type not in self._loaded:
90
+ spec = self._specs[runner_type]
91
+ self._loaded[runner_type] = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
92
+ return self._loaded[runner_type]
93
+
94
+ def __contains__(self, runner_type: object) -> bool:
95
+ return runner_type in self._specs
96
+
97
+
98
+ DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
99
+
100
+
101
+ def get_runner(runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY) -> Runner:
102
+ """Resolve one of the four static adapter types lazily."""
103
+
104
+ return registry.get(runner_type)