ai-push-hooks 0.3.1 → 0.3.2
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/CHANGELOG.md +49 -1
- package/README.md +91 -80
- package/ai-push-hooks.toml +1 -1
- package/docs/configuration.md +263 -0
- package/package.json +3 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -6
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +220 -49
- package/src/ai_push_hooks/engine.py +93 -43
- package/src/ai_push_hooks/executors/apply.py +121 -43
- package/src/ai_push_hooks/executors/ask.py +30 -13
- package/src/ai_push_hooks/executors/exec.py +61 -22
- package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
- package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
- package/src/ai_push_hooks/executors/runners/process.py +26 -7
- package/src/ai_push_hooks/executors/runners/registry.py +14 -5
- package/src/ai_push_hooks/executors/step_commands.py +58 -18
- package/src/ai_push_hooks/git_utils.py +92 -27
- package/src/ai_push_hooks/hook.py +48 -12
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +18 -7
- package/src/ai_push_hooks/modules/docs.py +17 -7
- package/src/ai_push_hooks/modules/pr.py +18 -7
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +79 -26
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +47 -27
- package/run.sh +0 -29
|
@@ -31,7 +31,9 @@ def extract_json_object(text: str) -> dict[str, Any]:
|
|
|
31
31
|
try:
|
|
32
32
|
payload = json.loads(text[start : end + 1])
|
|
33
33
|
except json.JSONDecodeError as exc:
|
|
34
|
-
raise HookError(
|
|
34
|
+
raise HookError(
|
|
35
|
+
f"Failed to parse JSON object from model output: {exc}"
|
|
36
|
+
) from exc
|
|
35
37
|
if not isinstance(payload, dict):
|
|
36
38
|
raise HookError("Model output JSON is not an object")
|
|
37
39
|
return payload
|
|
@@ -41,7 +43,9 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
41
43
|
if schema is None:
|
|
42
44
|
return payload
|
|
43
45
|
if schema == "string_array":
|
|
44
|
-
if not isinstance(payload, list) or not all(
|
|
46
|
+
if not isinstance(payload, list) or not all(
|
|
47
|
+
isinstance(item, str) for item in payload
|
|
48
|
+
):
|
|
45
49
|
raise HookError("Expected schema string_array")
|
|
46
50
|
return payload
|
|
47
51
|
if schema == "docs_issue_array":
|
|
@@ -50,7 +54,10 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
50
54
|
for item in payload:
|
|
51
55
|
if not isinstance(item, dict):
|
|
52
56
|
raise HookError("docs_issue_array items must be objects")
|
|
53
|
-
if
|
|
57
|
+
if (
|
|
58
|
+
not str(item.get("file", "")).strip()
|
|
59
|
+
or not str(item.get("description", "")).strip()
|
|
60
|
+
):
|
|
54
61
|
raise HookError("docs_issue_array items require file and description")
|
|
55
62
|
return payload
|
|
56
63
|
if schema == "beads_alignment_result":
|
|
@@ -58,13 +65,21 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
58
65
|
raise HookError("Expected schema beads_alignment_result")
|
|
59
66
|
commands = payload.get("commands", [])
|
|
60
67
|
if commands is not None and (
|
|
61
|
-
not isinstance(commands, list)
|
|
68
|
+
not isinstance(commands, list)
|
|
69
|
+
or not all(isinstance(item, str) for item in commands)
|
|
62
70
|
):
|
|
63
|
-
raise HookError(
|
|
71
|
+
raise HookError(
|
|
72
|
+
"beads_alignment_result.commands must be an array of strings"
|
|
73
|
+
)
|
|
64
74
|
return payload
|
|
65
75
|
if schema == "pr_create_payload":
|
|
66
76
|
if not isinstance(payload, dict):
|
|
67
77
|
raise HookError("Expected schema pr_create_payload")
|
|
78
|
+
for field in ("title", "body"):
|
|
79
|
+
if field in payload and not isinstance(payload[field], str):
|
|
80
|
+
raise HookError(f"pr_create_payload.{field} must be a string")
|
|
81
|
+
if "draft" in payload and not isinstance(payload["draft"], bool):
|
|
82
|
+
raise HookError("pr_create_payload.draft must be a boolean")
|
|
68
83
|
return payload
|
|
69
84
|
raise HookError(f"Unsupported schema: {schema}")
|
|
70
85
|
|
|
@@ -149,7 +164,9 @@ def run_ask_step(
|
|
|
149
164
|
f"{_safe_invalid_output(invocation, last_output)}"
|
|
150
165
|
) from exc
|
|
151
166
|
|
|
152
|
-
snippet = last_output[
|
|
167
|
+
snippet = last_output[
|
|
168
|
+
: context.config.llm.invalid_json_feedback_max_chars
|
|
169
|
+
]
|
|
153
170
|
suffix = (
|
|
154
171
|
"Return ONLY valid JSON array."
|
|
155
172
|
if expects_json_array
|
|
@@ -167,7 +184,11 @@ def run_ask_step(
|
|
|
167
184
|
|
|
168
185
|
session = result.session
|
|
169
186
|
can_resume = bool(
|
|
170
|
-
getattr(
|
|
187
|
+
getattr(
|
|
188
|
+
getattr(invocation.runner, "capabilities", None),
|
|
189
|
+
"supports_resume",
|
|
190
|
+
False,
|
|
191
|
+
)
|
|
171
192
|
and session is not None
|
|
172
193
|
and session.session_id
|
|
173
194
|
and session.resumable
|
|
@@ -181,13 +202,9 @@ def run_ask_step(
|
|
|
181
202
|
retry_reason = "runner does not support resume"
|
|
182
203
|
retry_message = "Retrying with a fresh runner invocation."
|
|
183
204
|
if retry_reason == "runner does not support resume":
|
|
184
|
-
retry_message =
|
|
185
|
-
"Retrying with a fresh runner invocation; unsupported session reuse."
|
|
186
|
-
)
|
|
205
|
+
retry_message = "Retrying with a fresh runner invocation; unsupported session reuse."
|
|
187
206
|
elif retry_reason == "session absent":
|
|
188
|
-
retry_message =
|
|
189
|
-
"Retrying with a fresh runner invocation; no reusable session was captured."
|
|
190
|
-
)
|
|
207
|
+
retry_message = "Retrying with a fresh runner invocation; no reusable session was captured."
|
|
191
208
|
context.logger.status(
|
|
192
209
|
"llm.retry_fresh_session",
|
|
193
210
|
retry_message,
|
|
@@ -20,6 +20,7 @@ from ..paths import (
|
|
|
20
20
|
resolve_contained_path,
|
|
21
21
|
)
|
|
22
22
|
from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
|
|
23
|
+
from .ask import validate_schema
|
|
23
24
|
|
|
24
25
|
BEADS_ALIGNMENT_TIMEOUT_SECONDS = 30
|
|
25
26
|
BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS = 120
|
|
@@ -72,10 +73,14 @@ BEADS_MIGRATION_OVERRIDE_ENV_NAMES = frozenset(
|
|
|
72
73
|
)
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
def _report_file_path(
|
|
76
|
+
def _report_file_path(
|
|
77
|
+
context: RuntimeContext, state: ModuleRuntimeState
|
|
78
|
+
) -> pathlib.Path:
|
|
76
79
|
branch_context = state.artifacts.get("collect/branch-context.txt")
|
|
77
80
|
if branch_context and branch_context.exists():
|
|
78
|
-
payload = git_utils.parse_key_value_text(
|
|
81
|
+
payload = git_utils.parse_key_value_text(
|
|
82
|
+
branch_context.read_text(encoding="utf-8")
|
|
83
|
+
)
|
|
79
84
|
report_file = payload.get("report_file", "BEADS_STATUS_ACTION_REQUIRED.md")
|
|
80
85
|
else:
|
|
81
86
|
report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
|
|
@@ -101,13 +106,19 @@ def _validate_beads_issue_ids(values: list[str]) -> None:
|
|
|
101
106
|
raise HookError("Beads alignment commands require between 1 and 20 issue ids")
|
|
102
107
|
for issue_id in values:
|
|
103
108
|
if not BEADS_ISSUE_ID_PATTERN.fullmatch(issue_id):
|
|
104
|
-
raise HookError(
|
|
109
|
+
raise HookError(
|
|
110
|
+
f"Invalid Beads issue id in alignment command: {issue_id!r}"
|
|
111
|
+
)
|
|
105
112
|
|
|
106
113
|
|
|
107
114
|
def validate_beads_alignment_command(command: str) -> list[str]:
|
|
108
115
|
if not isinstance(command, str) or not command.strip():
|
|
109
116
|
raise HookError("Beads alignment commands must be non-empty strings")
|
|
110
|
-
if
|
|
117
|
+
if (
|
|
118
|
+
len(command) > 4096
|
|
119
|
+
or "\x00" in command
|
|
120
|
+
or any(ord(char) < 32 for char in command)
|
|
121
|
+
):
|
|
111
122
|
raise HookError("Beads alignment command contains invalid or excessive input")
|
|
112
123
|
try:
|
|
113
124
|
argv = shlex.split(command, posix=True)
|
|
@@ -119,7 +130,11 @@ def validate_beads_alignment_command(command: str) -> list[str]:
|
|
|
119
130
|
|
|
120
131
|
subcommand = argv[1]
|
|
121
132
|
if subcommand == "update":
|
|
122
|
-
if
|
|
133
|
+
if (
|
|
134
|
+
len(argv) < 5
|
|
135
|
+
or argv[-2] != "--status"
|
|
136
|
+
or argv[-1] not in BEADS_UPDATE_STATUSES
|
|
137
|
+
):
|
|
123
138
|
raise HookError(
|
|
124
139
|
"Allowed Beads update form is: bd update <issue-id> [<issue-id> ...] "
|
|
125
140
|
"--status <open|in_progress|blocked>"
|
|
@@ -154,14 +169,18 @@ def resolve_beads_executable(repo_root: pathlib.Path) -> str:
|
|
|
154
169
|
lexical_candidate = pathlib.Path(os.path.abspath(candidate))
|
|
155
170
|
resolved_repo_root = repo_root.resolve(strict=True)
|
|
156
171
|
if is_path_within(lexical_candidate, resolved_repo_root):
|
|
157
|
-
raise HookError(
|
|
172
|
+
raise HookError(
|
|
173
|
+
f"Refusing repository-contained `bd` executable: {lexical_candidate}"
|
|
174
|
+
)
|
|
158
175
|
try:
|
|
159
176
|
executable = lexical_candidate.resolve(strict=True)
|
|
160
177
|
except (OSError, RuntimeError) as exc:
|
|
161
178
|
raise HookError("Unable to safely resolve the `bd` executable") from exc
|
|
162
179
|
if is_path_within(executable, resolved_repo_root):
|
|
163
180
|
raise HookError(f"Refusing repository-contained `bd` executable: {executable}")
|
|
164
|
-
if path_is_link_or_reparse(executable) or not stat.S_ISREG(
|
|
181
|
+
if path_is_link_or_reparse(executable) or not stat.S_ISREG(
|
|
182
|
+
executable.stat().st_mode
|
|
183
|
+
):
|
|
165
184
|
raise HookError(f"Resolved `bd` executable is not a regular file: {executable}")
|
|
166
185
|
if not os.access(executable, os.X_OK):
|
|
167
186
|
raise HookError(f"Resolved `bd` executable is not executable: {executable}")
|
|
@@ -184,7 +203,12 @@ def beads_alignment_executor(
|
|
|
184
203
|
inputs: list[pathlib.Path],
|
|
185
204
|
) -> dict[str, Any]:
|
|
186
205
|
if state.metadata.get("skip_module"):
|
|
187
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
"skipped": True,
|
|
208
|
+
"commands_run": [],
|
|
209
|
+
"report_written": False,
|
|
210
|
+
"unresolved": False,
|
|
211
|
+
}
|
|
188
212
|
payload = json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
189
213
|
if not isinstance(payload, dict):
|
|
190
214
|
raise HookError("beads_alignment payload must be an object")
|
|
@@ -195,14 +219,18 @@ def beads_alignment_executor(
|
|
|
195
219
|
raise HookError(
|
|
196
220
|
f"beads_alignment accepts at most {BEADS_ALIGNMENT_MAX_COMMANDS} commands"
|
|
197
221
|
)
|
|
198
|
-
validated_commands = [
|
|
222
|
+
validated_commands = [
|
|
223
|
+
validate_beads_alignment_command(command) for command in commands
|
|
224
|
+
]
|
|
199
225
|
beads_executable = resolve_beads_executable(context.repo_root) if commands else ""
|
|
200
226
|
command_env = beads_alignment_env()
|
|
201
227
|
report_path = _report_file_path(context, state)
|
|
202
228
|
commands_run: list[str] = []
|
|
203
229
|
started_at = time.monotonic()
|
|
204
230
|
for command, argv in zip(commands, validated_commands):
|
|
205
|
-
remaining = BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS - (
|
|
231
|
+
remaining = BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS - (
|
|
232
|
+
time.monotonic() - started_at
|
|
233
|
+
)
|
|
206
234
|
if remaining <= 0:
|
|
207
235
|
raise HookError(
|
|
208
236
|
f"Beads alignment exceeded its {BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS}-second total budget"
|
|
@@ -223,7 +251,9 @@ def beads_alignment_executor(
|
|
|
223
251
|
if report_markdown:
|
|
224
252
|
if not report_markdown.endswith("\n"):
|
|
225
253
|
report_markdown += "\n"
|
|
226
|
-
if not git_utils.write_text_file(
|
|
254
|
+
if not git_utils.write_text_file(
|
|
255
|
+
report_path, report_markdown, root=context.repo_root
|
|
256
|
+
):
|
|
227
257
|
raise HookError(f"Failed to write Beads alignment report: {report_path}")
|
|
228
258
|
report_written = True
|
|
229
259
|
elif report_path.exists() and not unresolved:
|
|
@@ -253,7 +283,9 @@ def gh_pr_create_executor(
|
|
|
253
283
|
branch_name = str(context.cache.get("branch_name", "")).strip()
|
|
254
284
|
if not branch_name:
|
|
255
285
|
reason = str(
|
|
256
|
-
context.cache.get(
|
|
286
|
+
context.cache.get(
|
|
287
|
+
"branch_selection_reason", "no single pushed branch is available"
|
|
288
|
+
)
|
|
257
289
|
)
|
|
258
290
|
raise HookError(f"PR creation requires one pushed branch: {reason}")
|
|
259
291
|
default_base_branch = context.config.general.base_branch.strip() or "main"
|
|
@@ -266,14 +298,14 @@ def gh_pr_create_executor(
|
|
|
266
298
|
"deferred_until_remote": True,
|
|
267
299
|
"reason": reason,
|
|
268
300
|
}
|
|
301
|
+
payload = validate_schema(
|
|
302
|
+
"pr_create_payload", json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
303
|
+
)
|
|
269
304
|
if shutil.which("gh") is None:
|
|
270
305
|
raise HookError("`gh` is required for PR creation but is not installed")
|
|
271
306
|
repository = git_utils.resolve_github_repository(
|
|
272
307
|
context.repo_root, context.remote_name, context.remote_url
|
|
273
308
|
)
|
|
274
|
-
payload = json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
275
|
-
if not isinstance(payload, dict):
|
|
276
|
-
raise HookError("PR creation payload must be an object")
|
|
277
309
|
existing_pr = git_utils.lookup_open_pr_url(
|
|
278
310
|
context.repo_root, branch_name, default_base_branch, repository
|
|
279
311
|
)
|
|
@@ -282,8 +314,8 @@ def gh_pr_create_executor(
|
|
|
282
314
|
|
|
283
315
|
base_branch = default_base_branch
|
|
284
316
|
head_branch = branch_name
|
|
285
|
-
title = git_utils.sanitize_pr_title(
|
|
286
|
-
body =
|
|
317
|
+
title = git_utils.sanitize_pr_title(payload.get("title", "").strip(), branch_name)
|
|
318
|
+
body = payload.get("body", "").strip()
|
|
287
319
|
if not body:
|
|
288
320
|
commits = git_utils.collect_commit_messages_for_ranges(
|
|
289
321
|
context.repo_root,
|
|
@@ -312,17 +344,24 @@ def gh_pr_create_executor(
|
|
|
312
344
|
"--body",
|
|
313
345
|
body,
|
|
314
346
|
]
|
|
315
|
-
if
|
|
347
|
+
if payload.get("draft", False):
|
|
316
348
|
args.append("--draft")
|
|
317
349
|
created = git_utils.run_command(args, cwd=context.repo_root, check=False)
|
|
318
|
-
combined_output = "\n".join(
|
|
319
|
-
|
|
320
|
-
|
|
350
|
+
combined_output = "\n".join(
|
|
351
|
+
[(created.stdout or "").strip(), (created.stderr or "").strip()]
|
|
352
|
+
)
|
|
353
|
+
if created.returncode != 0:
|
|
354
|
+
# A URL in failed-command output is not proof that the create operation
|
|
355
|
+
# succeeded. Reconcile against GitHub before accepting the result.
|
|
321
356
|
pr_url = git_utils.lookup_open_pr_url(
|
|
322
357
|
context.repo_root, branch_name, default_base_branch, repository
|
|
323
358
|
)
|
|
359
|
+
else:
|
|
360
|
+
pr_url = git_utils.extract_pr_url(combined_output)
|
|
324
361
|
if not pr_url:
|
|
325
|
-
details = git_utils._command_diagnostics(
|
|
362
|
+
details = git_utils._command_diagnostics(
|
|
363
|
+
args, created.stdout or "", created.stderr or ""
|
|
364
|
+
)
|
|
326
365
|
raise HookError(
|
|
327
366
|
details or f"gh pr create failed with exit code {created.returncode}"
|
|
328
367
|
)
|
|
@@ -76,29 +76,36 @@ def _safe_input_artifacts(
|
|
|
76
76
|
# parents immediately before opening each file; this is a same-user
|
|
77
77
|
# race defense, not an OS sandbox. O_NOFOLLOW and descriptor checks
|
|
78
78
|
# below protect the final open even if the leaf changes concurrently.
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
or path_has_symlink(run_root, lexical_path)
|
|
79
|
+
if not is_path_within(lexical_path, run_root) or path_has_symlink(
|
|
80
|
+
run_root, lexical_path
|
|
82
81
|
):
|
|
83
82
|
raise HookError(f"Runner artifact must not traverse a symlink: {name}")
|
|
84
83
|
try:
|
|
85
84
|
resolved_path = lexical_path.resolve(strict=True)
|
|
86
85
|
except (OSError, RuntimeError) as exc:
|
|
87
|
-
raise HookError(
|
|
86
|
+
raise HookError(
|
|
87
|
+
f"Unable to safely resolve runner artifact: {name}"
|
|
88
|
+
) from exc
|
|
88
89
|
if (
|
|
89
90
|
resolved_path != lexical_path
|
|
90
91
|
or not is_path_within(resolved_path, run_root)
|
|
91
92
|
or not resolved_path.is_file()
|
|
92
93
|
):
|
|
93
|
-
raise HookError(
|
|
94
|
+
raise HookError(
|
|
95
|
+
f"Runner artifact must be a regular hook-owned file: {name}"
|
|
96
|
+
)
|
|
94
97
|
|
|
95
98
|
descriptor: int | None = None
|
|
96
99
|
try:
|
|
97
|
-
flags =
|
|
100
|
+
flags = (
|
|
101
|
+
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
102
|
+
)
|
|
98
103
|
descriptor = os.open(lexical_path, flags)
|
|
99
104
|
metadata = os.fstat(descriptor)
|
|
100
105
|
if not os.path.isfile(lexical_path) or not stat.S_ISREG(metadata.st_mode):
|
|
101
|
-
raise HookError(
|
|
106
|
+
raise HookError(
|
|
107
|
+
f"Runner artifact must be a regular hook-owned file: {name}"
|
|
108
|
+
)
|
|
102
109
|
remaining = RUNNER_INPUT_MAX_BYTES - total_bytes
|
|
103
110
|
if metadata.st_size > remaining:
|
|
104
111
|
raise HookError(
|
|
@@ -121,7 +128,9 @@ def _safe_input_artifacts(
|
|
|
121
128
|
total_bytes += len(content_bytes)
|
|
122
129
|
content = bytes(content_bytes).decode("utf-8")
|
|
123
130
|
except (OSError, UnicodeError) as exc:
|
|
124
|
-
raise HookError(
|
|
131
|
+
raise HookError(
|
|
132
|
+
f"Unable to read hook-owned runner artifact: {name}"
|
|
133
|
+
) from exc
|
|
125
134
|
finally:
|
|
126
135
|
if descriptor is not None:
|
|
127
136
|
os.close(descriptor)
|
|
@@ -143,7 +152,9 @@ def _build_request(
|
|
|
143
152
|
profile = resolve_runner_profile(context.config, step)
|
|
144
153
|
cwd = pathlib.Path(working_directory).resolve(strict=True)
|
|
145
154
|
if not cwd.is_dir():
|
|
146
|
-
raise HookError(
|
|
155
|
+
raise HookError(
|
|
156
|
+
f"Runner working directory is not a directory: {working_directory}"
|
|
157
|
+
)
|
|
147
158
|
request = RunnerRequest(
|
|
148
159
|
profile_id=profile.name,
|
|
149
160
|
runner_type=profile.type,
|
|
@@ -206,7 +217,9 @@ def _failure_result(
|
|
|
206
217
|
if not isinstance(session_id, str) or not session_id.strip():
|
|
207
218
|
session_id = fallback_session_id
|
|
208
219
|
session = _session_metadata(runner, session_id.strip()) if session_id else None
|
|
209
|
-
return RunnerResult(
|
|
220
|
+
return RunnerResult(
|
|
221
|
+
final_text="", returncode=1, stdout="", stderr="", session=session
|
|
222
|
+
)
|
|
210
223
|
|
|
211
224
|
|
|
212
225
|
def _preserve_failure_session(
|
|
@@ -214,7 +227,9 @@ def _preserve_failure_session(
|
|
|
214
227
|
result: RunnerResult,
|
|
215
228
|
fallback_session_id: str | None,
|
|
216
229
|
) -> RunnerResult:
|
|
217
|
-
if (
|
|
230
|
+
if (
|
|
231
|
+
result.session is not None and result.session.session_id
|
|
232
|
+
) or not fallback_session_id:
|
|
218
233
|
return result
|
|
219
234
|
return RunnerResult(
|
|
220
235
|
final_text=result.final_text,
|
|
@@ -250,7 +265,9 @@ def _completion(
|
|
|
250
265
|
)
|
|
251
266
|
|
|
252
267
|
|
|
253
|
-
def _print_normalized_output(
|
|
268
|
+
def _print_normalized_output(
|
|
269
|
+
context: RuntimeContext, invocation: _RunnerInvocation
|
|
270
|
+
) -> None:
|
|
254
271
|
if not context.config.logging.print_llm_output or not invocation.result.final_text:
|
|
255
272
|
return
|
|
256
273
|
# This is an explicit opt-in. Only normalized final text is printed and
|
|
@@ -270,7 +287,14 @@ def _request_sensitive_values(request: RunnerRequest) -> tuple[str, ...]:
|
|
|
270
287
|
if value
|
|
271
288
|
and any(
|
|
272
289
|
marker in name.upper()
|
|
273
|
-
for marker in (
|
|
290
|
+
for marker in (
|
|
291
|
+
"API_KEY",
|
|
292
|
+
"TOKEN",
|
|
293
|
+
"SECRET",
|
|
294
|
+
"PASSWORD",
|
|
295
|
+
"AUTH",
|
|
296
|
+
"CREDENTIAL",
|
|
297
|
+
)
|
|
274
298
|
)
|
|
275
299
|
)
|
|
276
300
|
return (
|
|
@@ -316,9 +340,7 @@ def _named_error(
|
|
|
316
340
|
"\n".join(part for part in (reason, streams) if part),
|
|
317
341
|
max_chars=1_200,
|
|
318
342
|
)
|
|
319
|
-
message = (
|
|
320
|
-
f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
|
|
321
|
-
)
|
|
343
|
+
message = f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
|
|
322
344
|
if details:
|
|
323
345
|
message += f": {details}"
|
|
324
346
|
return HookError(message)
|
|
@@ -65,7 +65,9 @@ def resolve_claude_executable() -> str:
|
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def _capability_error(
|
|
68
|
+
def _capability_error(
|
|
69
|
+
reason: str, *, details: str = ""
|
|
70
|
+
) -> RunnerAdapterUnavailableError:
|
|
69
71
|
return RunnerAdapterUnavailableError(
|
|
70
72
|
"Claude Code CLI does not satisfy the required non-interactive contract",
|
|
71
73
|
details=f"{reason}{(': ' + details) if details else ''}",
|
|
@@ -102,9 +104,13 @@ def check_claude_capabilities(
|
|
|
102
104
|
raise _capability_error("help output was truncated")
|
|
103
105
|
|
|
104
106
|
help_text = f"{help_result.stdout}\n{help_result.stderr}"
|
|
105
|
-
missing = tuple(
|
|
107
|
+
missing = tuple(
|
|
108
|
+
marker for marker in REQUIRED_HELP_MARKERS if marker not in help_text
|
|
109
|
+
)
|
|
106
110
|
if missing:
|
|
107
|
-
raise _capability_error(
|
|
111
|
+
raise _capability_error(
|
|
112
|
+
"missing required flags or modes", details=", ".join(missing)
|
|
113
|
+
)
|
|
108
114
|
|
|
109
115
|
|
|
110
116
|
def _protocol_failure(
|
|
@@ -158,13 +164,19 @@ def parse_claude_result(
|
|
|
158
164
|
try:
|
|
159
165
|
payload: Any = json.loads(raw)
|
|
160
166
|
except json.JSONDecodeError as exc:
|
|
161
|
-
raise _protocol_failure(
|
|
167
|
+
raise _protocol_failure(
|
|
168
|
+
request, process_result, "stdout was not valid JSON"
|
|
169
|
+
) from exc
|
|
162
170
|
if not isinstance(payload, dict):
|
|
163
|
-
raise _protocol_failure(
|
|
171
|
+
raise _protocol_failure(
|
|
172
|
+
request, process_result, "top-level JSON value was not an object"
|
|
173
|
+
)
|
|
164
174
|
|
|
165
175
|
message_type = payload.get("type")
|
|
166
176
|
if message_type != "result":
|
|
167
|
-
raise _protocol_failure(
|
|
177
|
+
raise _protocol_failure(
|
|
178
|
+
request, process_result, "top-level JSON type was not result"
|
|
179
|
+
)
|
|
168
180
|
|
|
169
181
|
is_error = payload.get("is_error")
|
|
170
182
|
if not isinstance(is_error, bool):
|
|
@@ -111,14 +111,18 @@ def _parse_codex_jsonl(request: RunnerRequest, process: ProcessResult) -> _Codex
|
|
|
111
111
|
thread_id = value
|
|
112
112
|
elif event_type == "turn.started":
|
|
113
113
|
if turn_open:
|
|
114
|
-
raise _protocol_error(
|
|
114
|
+
raise _protocol_error(
|
|
115
|
+
request, process, "Codex JSONL turn order was invalid"
|
|
116
|
+
)
|
|
115
117
|
turn_open = True
|
|
116
118
|
if not terminal_failure:
|
|
117
119
|
terminal_success = False
|
|
118
120
|
elif event_type == "item.completed":
|
|
119
121
|
item = event.get("item")
|
|
120
122
|
if not isinstance(item, dict):
|
|
121
|
-
raise _protocol_error(
|
|
123
|
+
raise _protocol_error(
|
|
124
|
+
request, process, "Codex item.completed event had no item"
|
|
125
|
+
)
|
|
122
126
|
if item.get("type") == "agent_message":
|
|
123
127
|
text = item.get("text")
|
|
124
128
|
if not isinstance(text, str):
|
|
@@ -213,7 +217,9 @@ class CodexRunner:
|
|
|
213
217
|
if process.stdout_truncated or process.stderr_truncated:
|
|
214
218
|
raise _protocol_error(request, process, "Codex output stream was truncated")
|
|
215
219
|
if not process.stdout.strip():
|
|
216
|
-
raise _protocol_error(
|
|
220
|
+
raise _protocol_error(
|
|
221
|
+
request, process, "Codex emitted an empty JSONL stream"
|
|
222
|
+
)
|
|
217
223
|
|
|
218
224
|
parsed = _parse_codex_jsonl(request, process)
|
|
219
225
|
if not parsed.terminal_success:
|
|
@@ -25,8 +25,10 @@ _PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _is_plain_text(value: object, *, allow_empty: bool = False) -> bool:
|
|
28
|
-
return
|
|
29
|
-
|
|
28
|
+
return (
|
|
29
|
+
isinstance(value, str)
|
|
30
|
+
and (allow_empty or bool(value.strip()))
|
|
31
|
+
and not any(ord(character) < 32 for character in value)
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
|
|
@@ -69,10 +71,14 @@ def _validate_request(request: object) -> RunnerRequest:
|
|
|
69
71
|
or not math.isfinite(request.timeout_seconds)
|
|
70
72
|
or request.timeout_seconds <= 0
|
|
71
73
|
):
|
|
72
|
-
raise RunnerContractError(
|
|
74
|
+
raise RunnerContractError(
|
|
75
|
+
"timeout_seconds must be finite and greater than zero"
|
|
76
|
+
)
|
|
73
77
|
|
|
74
78
|
if request.model is not None and not _is_plain_text(request.model):
|
|
75
|
-
raise RunnerContractError(
|
|
79
|
+
raise RunnerContractError(
|
|
80
|
+
"model must be a non-empty NUL-free string when provided"
|
|
81
|
+
)
|
|
76
82
|
if request.variant is not None:
|
|
77
83
|
raise RunnerContractError("variant is not valid for runner type command")
|
|
78
84
|
if request.prompt_transport not in {"stdin", "argv"}:
|
|
@@ -85,11 +91,17 @@ def _validate_request(request: object) -> RunnerRequest:
|
|
|
85
91
|
prompt_count = 0
|
|
86
92
|
for index, argument in enumerate(command, start=1):
|
|
87
93
|
if not isinstance(argument, str) or not argument.strip() or "\x00" in argument:
|
|
88
|
-
raise RunnerContractError(
|
|
94
|
+
raise RunnerContractError(
|
|
95
|
+
f"command[{index}] must be a non-empty NUL-free string"
|
|
96
|
+
)
|
|
89
97
|
for placeholder in _PLACEHOLDER_PATTERN.findall(argument):
|
|
90
98
|
if placeholder not in _ALLOWED_PLACEHOLDERS:
|
|
91
|
-
raise RunnerContractError(
|
|
92
|
-
|
|
99
|
+
raise RunnerContractError(
|
|
100
|
+
f"unknown placeholder {placeholder!r} in command[{index}]"
|
|
101
|
+
)
|
|
102
|
+
if (
|
|
103
|
+
"{" in argument or "}" in argument
|
|
104
|
+
) and argument not in _ALLOWED_PLACEHOLDERS:
|
|
93
105
|
raise RunnerContractError(
|
|
94
106
|
f"placeholders in command[{index}] must be whole argv elements"
|
|
95
107
|
)
|
|
@@ -97,7 +109,9 @@ def _validate_request(request: object) -> RunnerRequest:
|
|
|
97
109
|
prompt_count += 1
|
|
98
110
|
|
|
99
111
|
if request.prompt_transport == "stdin" and prompt_count:
|
|
100
|
-
raise RunnerContractError(
|
|
112
|
+
raise RunnerContractError(
|
|
113
|
+
"command must not contain {prompt} with stdin transport"
|
|
114
|
+
)
|
|
101
115
|
if request.prompt_transport == "argv" and prompt_count != 1:
|
|
102
116
|
raise RunnerContractError(
|
|
103
117
|
"command must contain exactly one {prompt} with argv transport"
|
|
@@ -142,7 +156,9 @@ class CommandRunner:
|
|
|
142
156
|
except RunnerExecutableNotFoundError as exc:
|
|
143
157
|
# The rendered argv can contain prompt/model values. Do not
|
|
144
158
|
# repeat the lower-level adapter's executable detail here.
|
|
145
|
-
raise RunnerExecutableNotFoundError(
|
|
159
|
+
raise RunnerExecutableNotFoundError(
|
|
160
|
+
"runner executable was not found"
|
|
161
|
+
) from exc
|
|
146
162
|
result = RunnerResult(
|
|
147
163
|
final_text=process_result.stdout,
|
|
148
164
|
returncode=process_result.returncode,
|