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,178 @@
1
+ """Shell-free command runner adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import pathlib
8
+ import re
9
+
10
+ from .contracts import (
11
+ RunnerCapabilities,
12
+ RunnerContractError,
13
+ RunnerExecutableNotFoundError,
14
+ RunnerProtocolError,
15
+ RunnerRequest,
16
+ RunnerResult,
17
+ require_final_text,
18
+ require_zero_exit,
19
+ )
20
+ from .process import run_process
21
+
22
+
23
+ _ALLOWED_PLACEHOLDERS = frozenset({"{prompt}", "{model}", "{cwd}", "{stage}"})
24
+ _PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
25
+
26
+
27
+ def _is_plain_text(value: object, *, allow_empty: bool = False) -> bool:
28
+ return isinstance(value, str) and (allow_empty or bool(value.strip())) and not any(
29
+ ord(character) < 32 for character in value
30
+ )
31
+
32
+
33
+ def _validate_request(request: object) -> RunnerRequest:
34
+ """Re-check adapter-specific invariants at the process boundary.
35
+
36
+ ``RunnerRequest`` validates normal construction. This second check is
37
+ intentional: callers can still pass objects assembled by deserializers or
38
+ mutate frozen instances with low-level Python APIs before invoking an
39
+ adapter.
40
+ """
41
+
42
+ if not isinstance(request, RunnerRequest):
43
+ raise RunnerContractError("command runner requires a RunnerRequest")
44
+
45
+ for value, label in (
46
+ (request.profile_id, "profile_id"),
47
+ (request.stage, "stage"),
48
+ (request.purpose, "purpose"),
49
+ ):
50
+ if not _is_plain_text(value):
51
+ raise RunnerContractError(f"{label} must be a non-empty NUL-free string")
52
+ if request.runner_type != "command":
53
+ raise RunnerContractError("command runner requires runner_type 'command'")
54
+ if request.mode not in {"ask", "apply"}:
55
+ raise RunnerContractError("mode must be 'ask' or 'apply'")
56
+ if not isinstance(request.instruction, str) or "\x00" in request.instruction:
57
+ raise RunnerContractError("instruction must be a NUL-free string")
58
+ if any(
59
+ ord(character) < 32 and character not in "\r\n\t"
60
+ for character in request.instruction
61
+ ):
62
+ raise RunnerContractError("instruction contains control characters")
63
+
64
+ if not isinstance(request.cwd, pathlib.Path) or "\x00" in str(request.cwd):
65
+ raise RunnerContractError("cwd must be a valid path")
66
+ if (
67
+ isinstance(request.timeout_seconds, bool)
68
+ or not isinstance(request.timeout_seconds, (int, float))
69
+ or not math.isfinite(request.timeout_seconds)
70
+ or request.timeout_seconds <= 0
71
+ ):
72
+ raise RunnerContractError("timeout_seconds must be finite and greater than zero")
73
+
74
+ if request.model is not None and not _is_plain_text(request.model):
75
+ raise RunnerContractError("model must be a non-empty NUL-free string when provided")
76
+ if request.variant is not None:
77
+ raise RunnerContractError("variant is not valid for runner type command")
78
+ if request.prompt_transport not in {"stdin", "argv"}:
79
+ raise RunnerContractError("prompt_transport must be 'stdin' or 'argv'")
80
+
81
+ command = request.command
82
+ if not isinstance(command, (tuple, list)) or not command:
83
+ raise RunnerContractError("command must be a non-empty argv sequence")
84
+
85
+ prompt_count = 0
86
+ for index, argument in enumerate(command, start=1):
87
+ if not isinstance(argument, str) or not argument.strip() or "\x00" in argument:
88
+ raise RunnerContractError(f"command[{index}] must be a non-empty NUL-free string")
89
+ for placeholder in _PLACEHOLDER_PATTERN.findall(argument):
90
+ if placeholder not in _ALLOWED_PLACEHOLDERS:
91
+ raise RunnerContractError(f"unknown placeholder {placeholder!r} in command[{index}]")
92
+ if ("{" in argument or "}" in argument) and argument not in _ALLOWED_PLACEHOLDERS:
93
+ raise RunnerContractError(
94
+ f"placeholders in command[{index}] must be whole argv elements"
95
+ )
96
+ if argument == "{prompt}":
97
+ prompt_count += 1
98
+
99
+ if request.prompt_transport == "stdin" and prompt_count:
100
+ raise RunnerContractError("command must not contain {prompt} with stdin transport")
101
+ if request.prompt_transport == "argv" and prompt_count != 1:
102
+ raise RunnerContractError(
103
+ "command must contain exactly one {prompt} with argv transport"
104
+ )
105
+ if "{model}" in command and not request.model:
106
+ raise RunnerContractError("command uses {model} but model is not configured")
107
+ return request
108
+
109
+
110
+ def _render_argv(request: RunnerRequest, packet: str) -> tuple[str, ...]:
111
+ replacements = {
112
+ "{prompt}": packet,
113
+ "{model}": request.model or "",
114
+ "{cwd}": str(request.cwd),
115
+ "{stage}": request.stage,
116
+ }
117
+ return tuple(replacements.get(argument, argument) for argument in request.command)
118
+
119
+
120
+ class CommandRunner:
121
+ """Execute a configured command as a direct argv vector, never via a shell."""
122
+
123
+ capabilities = RunnerCapabilities()
124
+
125
+ def run(self, request: RunnerRequest) -> RunnerResult:
126
+ request = _validate_request(request)
127
+ packet = request.prompt_packet().render()
128
+ argv = _render_argv(request, packet)
129
+
130
+ # ``None`` tells subprocess to inherit the invoking user's complete
131
+ # environment. argv transport still receives an explicit empty input
132
+ # stream so the child observes EOF rather than an open stdin pipe.
133
+ input_text = packet if request.prompt_transport == "stdin" else ""
134
+ try:
135
+ process_result = run_process(
136
+ argv,
137
+ cwd=request.cwd,
138
+ input_text=input_text,
139
+ timeout_seconds=request.timeout_seconds,
140
+ env=None,
141
+ )
142
+ except RunnerExecutableNotFoundError as exc:
143
+ # The rendered argv can contain prompt/model values. Do not
144
+ # repeat the lower-level adapter's executable detail here.
145
+ raise RunnerExecutableNotFoundError("runner executable was not found") from exc
146
+ result = RunnerResult(
147
+ final_text=process_result.stdout,
148
+ returncode=process_result.returncode,
149
+ stdout=process_result.stdout,
150
+ stderr=process_result.stderr,
151
+ )
152
+ require_zero_exit(
153
+ request,
154
+ result,
155
+ env=os.environ,
156
+ secrets=tuple(value for value in (request.model,) if value),
157
+ )
158
+ if process_result.stdout_truncated or process_result.stderr_truncated:
159
+ streams = []
160
+ if process_result.stdout_truncated:
161
+ streams.append("stdout")
162
+ if process_result.stderr_truncated:
163
+ streams.append("stderr")
164
+ raise RunnerProtocolError(
165
+ "command runner output exceeded its capture limit",
166
+ details=" and ".join(streams),
167
+ )
168
+ require_final_text(result.final_text, mode=request.mode)
169
+ return result
170
+
171
+
172
+ def create_runner() -> CommandRunner:
173
+ """Construct the stateless command adapter."""
174
+
175
+ return CommandRunner()
176
+
177
+
178
+ __all__ = ["CommandRunner", "create_runner"]