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
|
@@ -88,7 +88,9 @@ def _validate_text(
|
|
|
88
88
|
if allow_controls:
|
|
89
89
|
return value
|
|
90
90
|
allowed_controls = "\r\n\t" if allow_line_breaks else ""
|
|
91
|
-
if "\x00" in value or any(
|
|
91
|
+
if "\x00" in value or any(
|
|
92
|
+
ord(character) < 32 and character not in allowed_controls for character in value
|
|
93
|
+
):
|
|
92
94
|
raise RunnerContractError(f"{label} contains control characters")
|
|
93
95
|
return value
|
|
94
96
|
|
|
@@ -156,7 +158,9 @@ class PromptPacket:
|
|
|
156
158
|
if not isinstance(self.artifacts, (tuple, list)):
|
|
157
159
|
raise RunnerContractError("prompt packet artifacts must be ordered")
|
|
158
160
|
if not all(isinstance(item, RunnerArtifact) for item in self.artifacts):
|
|
159
|
-
raise RunnerContractError(
|
|
161
|
+
raise RunnerContractError(
|
|
162
|
+
"prompt packet artifacts must contain RunnerArtifact values"
|
|
163
|
+
)
|
|
160
164
|
object.__setattr__(self, "artifacts", tuple(self.artifacts))
|
|
161
165
|
|
|
162
166
|
def render(self) -> str:
|
|
@@ -208,13 +212,17 @@ class RunnerRequest:
|
|
|
208
212
|
_validate_text(self.purpose, "purpose")
|
|
209
213
|
if self.mode not in {"ask", "apply"}:
|
|
210
214
|
raise RunnerContractError("mode must be 'ask' or 'apply'")
|
|
211
|
-
_validate_text(
|
|
215
|
+
_validate_text(
|
|
216
|
+
self.instruction, "instruction", allow_empty=True, allow_line_breaks=True
|
|
217
|
+
)
|
|
212
218
|
if not isinstance(self.artifacts, (tuple, list)):
|
|
213
219
|
raise RunnerContractError("artifacts must be an ordered sequence")
|
|
214
220
|
normalized_artifacts: list[RunnerArtifact] = []
|
|
215
221
|
for artifact in self.artifacts:
|
|
216
222
|
if not isinstance(artifact, RunnerArtifact):
|
|
217
|
-
raise RunnerContractError(
|
|
223
|
+
raise RunnerContractError(
|
|
224
|
+
"artifacts must contain RunnerArtifact values"
|
|
225
|
+
)
|
|
218
226
|
normalized_artifacts.append(artifact)
|
|
219
227
|
object.__setattr__(self, "artifacts", tuple(normalized_artifacts))
|
|
220
228
|
if not isinstance(self.cwd, pathlib.Path):
|
|
@@ -310,7 +318,9 @@ class RunnerResult:
|
|
|
310
318
|
transcript: str | None = field(default=None, repr=False)
|
|
311
319
|
|
|
312
320
|
def __post_init__(self) -> None:
|
|
313
|
-
_validate_text(
|
|
321
|
+
_validate_text(
|
|
322
|
+
self.final_text, "final_text", allow_empty=True, allow_controls=True
|
|
323
|
+
)
|
|
314
324
|
if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
|
|
315
325
|
raise RunnerContractError("returncode must be an integer")
|
|
316
326
|
_validate_text(self.stdout, "stdout", allow_empty=True, allow_controls=True)
|
|
@@ -318,7 +328,9 @@ class RunnerResult:
|
|
|
318
328
|
if self.session is not None and not isinstance(self.session, SessionMetadata):
|
|
319
329
|
raise RunnerContractError("session must be SessionMetadata or None")
|
|
320
330
|
if self.transcript is not None:
|
|
321
|
-
_validate_text(
|
|
331
|
+
_validate_text(
|
|
332
|
+
self.transcript, "transcript", allow_empty=True, allow_line_breaks=True
|
|
333
|
+
)
|
|
322
334
|
|
|
323
335
|
def __repr__(self) -> str:
|
|
324
336
|
return (
|
|
@@ -342,18 +354,20 @@ class Runner(Protocol):
|
|
|
342
354
|
|
|
343
355
|
capabilities: RunnerCapabilities
|
|
344
356
|
|
|
345
|
-
def run(self, request: RunnerRequest) -> RunnerResult:
|
|
346
|
-
...
|
|
357
|
+
def run(self, request: RunnerRequest) -> RunnerResult: ...
|
|
347
358
|
|
|
348
359
|
|
|
349
360
|
class RunnerLifecycle(Protocol):
|
|
350
361
|
"""Optional lifecycle API, implemented only by adapters that need it."""
|
|
351
362
|
|
|
352
|
-
def finalize(
|
|
353
|
-
|
|
363
|
+
def finalize(
|
|
364
|
+
self, request: RunnerRequest, result: RunnerResult
|
|
365
|
+
) -> RunnerResult: ...
|
|
354
366
|
|
|
355
367
|
|
|
356
|
-
def finalize_runner(
|
|
368
|
+
def finalize_runner(
|
|
369
|
+
runner: Runner, request: RunnerRequest, result: RunnerResult
|
|
370
|
+
) -> RunnerResult:
|
|
357
371
|
"""Finalize a result when the adapter explicitly advertises that capability."""
|
|
358
372
|
|
|
359
373
|
capabilities = getattr(runner, "capabilities", RunnerCapabilities())
|
|
@@ -396,9 +410,7 @@ def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
|
396
410
|
for pattern in _SECRET_PATTERNS:
|
|
397
411
|
redacted = pattern.sub(
|
|
398
412
|
lambda match: (
|
|
399
|
-
f"{match.group(1)}[REDACTED]"
|
|
400
|
-
if match.lastindex
|
|
401
|
-
else "[REDACTED]"
|
|
413
|
+
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
|
|
402
414
|
),
|
|
403
415
|
redacted,
|
|
404
416
|
)
|
|
@@ -450,7 +462,10 @@ def bounded_diagnostic(
|
|
|
450
462
|
return safe
|
|
451
463
|
if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
|
|
452
464
|
return DIAGNOSTIC_TRUNCATION_MARKER[:max_chars]
|
|
453
|
-
return
|
|
465
|
+
return (
|
|
466
|
+
safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)]
|
|
467
|
+
+ DIAGNOSTIC_TRUNCATION_MARKER
|
|
468
|
+
)
|
|
454
469
|
|
|
455
470
|
|
|
456
471
|
def bounded_redacted_diagnostics(
|
|
@@ -465,7 +480,9 @@ def bounded_redacted_diagnostics(
|
|
|
465
480
|
parts: list[str] = []
|
|
466
481
|
for label, value in (("stdout", stdout), ("stderr", stderr)):
|
|
467
482
|
if value:
|
|
468
|
-
parts.append(
|
|
483
|
+
parts.append(
|
|
484
|
+
f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}"
|
|
485
|
+
)
|
|
469
486
|
combined = "\n".join(parts)
|
|
470
487
|
return bounded_diagnostic(combined, max_chars=max_chars, secrets=secrets)
|
|
471
488
|
|
|
@@ -473,7 +490,14 @@ def bounded_redacted_diagnostics(
|
|
|
473
490
|
def _credential_environment_values(env: Mapping[str, str] | None) -> tuple[str, ...]:
|
|
474
491
|
if env is None:
|
|
475
492
|
return ()
|
|
476
|
-
credential_markers = (
|
|
493
|
+
credential_markers = (
|
|
494
|
+
"API_KEY",
|
|
495
|
+
"TOKEN",
|
|
496
|
+
"SECRET",
|
|
497
|
+
"PASSWORD",
|
|
498
|
+
"AUTH",
|
|
499
|
+
"CREDENTIAL",
|
|
500
|
+
)
|
|
477
501
|
return tuple(
|
|
478
502
|
value
|
|
479
503
|
for name, value in env.items()
|
|
@@ -582,14 +606,17 @@ def require_zero_exit(
|
|
|
582
606
|
"""Raise a bounded non-zero diagnostic while preserving normalized results."""
|
|
583
607
|
|
|
584
608
|
if result.returncode != 0:
|
|
585
|
-
details =
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
609
|
+
details = (
|
|
610
|
+
request_sensitive_diagnostics(
|
|
611
|
+
request,
|
|
612
|
+
result.stdout,
|
|
613
|
+
result.stderr,
|
|
614
|
+
max_chars=diagnostic_limit,
|
|
615
|
+
env=env,
|
|
616
|
+
extra=secrets,
|
|
617
|
+
)
|
|
618
|
+
or f"exit code {result.returncode}"
|
|
619
|
+
)
|
|
593
620
|
raise RunnerNonzeroExitError(
|
|
594
621
|
f"runner {request.profile_id!r} ({request.runner_type}) failed at {request.stage!r}",
|
|
595
622
|
details=details,
|
|
@@ -25,7 +25,11 @@ from .opencode_support import (
|
|
|
25
25
|
sanitize_filename_component,
|
|
26
26
|
validate_hook_owned_artifacts,
|
|
27
27
|
)
|
|
28
|
-
from ...paths import
|
|
28
|
+
from ...paths import (
|
|
29
|
+
ensure_private_directory,
|
|
30
|
+
resolve_contained_path,
|
|
31
|
+
write_text_no_follow,
|
|
32
|
+
)
|
|
29
33
|
from .process import ProcessResult
|
|
30
34
|
from .contracts import (
|
|
31
35
|
RunnerCapabilities,
|
|
@@ -65,7 +69,9 @@ class OpenCodeRunner:
|
|
|
65
69
|
session_id: str | None = None
|
|
66
70
|
text_parts: list[str] = []
|
|
67
71
|
|
|
68
|
-
def protocol_failure(
|
|
72
|
+
def protocol_failure(
|
|
73
|
+
message: str, *, missing: bool = False
|
|
74
|
+
) -> RunnerProtocolError:
|
|
69
75
|
error: RunnerProtocolError
|
|
70
76
|
if missing:
|
|
71
77
|
error = RunnerMissingOutputError(message)
|
|
@@ -86,7 +92,9 @@ class OpenCodeRunner:
|
|
|
86
92
|
try:
|
|
87
93
|
event = json.loads(line)
|
|
88
94
|
except json.JSONDecodeError as exc:
|
|
89
|
-
raise protocol_failure(
|
|
95
|
+
raise protocol_failure(
|
|
96
|
+
"OpenCode emitted malformed JSONL output"
|
|
97
|
+
) from exc
|
|
90
98
|
if not isinstance(event, dict):
|
|
91
99
|
raise protocol_failure("OpenCode emitted a non-object JSONL event")
|
|
92
100
|
|
|
@@ -220,7 +228,11 @@ class OpenCodeRunner:
|
|
|
220
228
|
*,
|
|
221
229
|
prompt: str,
|
|
222
230
|
) -> list[str]:
|
|
223
|
-
agent =
|
|
231
|
+
agent = (
|
|
232
|
+
OPENCODE_APPLY_AGENT
|
|
233
|
+
if request.mode == "apply"
|
|
234
|
+
else OPENCODE_READ_ONLY_AGENT
|
|
235
|
+
)
|
|
224
236
|
config = getattr(getattr(context, "config", None), "llm", None)
|
|
225
237
|
title_prefix = getattr(config, "session_title_prefix", "ai-push-hooks")
|
|
226
238
|
argv = [
|
|
@@ -251,7 +263,9 @@ class OpenCodeRunner:
|
|
|
251
263
|
return argv
|
|
252
264
|
|
|
253
265
|
@staticmethod
|
|
254
|
-
def _require_staging_directory(
|
|
266
|
+
def _require_staging_directory(
|
|
267
|
+
context: Any, working_directory: pathlib.Path
|
|
268
|
+
) -> None:
|
|
255
269
|
"""Reject repository worktrees as apply targets.
|
|
256
270
|
|
|
257
271
|
The apply workflow supplies a disposable, non-VCS staging projection.
|
|
@@ -262,11 +276,16 @@ class OpenCodeRunner:
|
|
|
262
276
|
"""
|
|
263
277
|
|
|
264
278
|
repository_root = context.repo_root.resolve(strict=True)
|
|
265
|
-
if working_directory == repository_root or working_directory.is_relative_to(
|
|
279
|
+
if working_directory == repository_root or working_directory.is_relative_to(
|
|
280
|
+
repository_root
|
|
281
|
+
):
|
|
266
282
|
raise RunnerContractError(
|
|
267
283
|
"OpenCode apply requests require an isolated staging directory"
|
|
268
284
|
)
|
|
269
|
-
if any(
|
|
285
|
+
if any(
|
|
286
|
+
(parent / ".git").exists()
|
|
287
|
+
for parent in (working_directory, *working_directory.parents)
|
|
288
|
+
):
|
|
270
289
|
raise RunnerContractError(
|
|
271
290
|
"OpenCode apply requests require a non-VCS isolated staging directory"
|
|
272
291
|
)
|
|
@@ -296,7 +315,9 @@ class OpenCodeRunner:
|
|
|
296
315
|
"OpenCode request cwd must be an existing directory"
|
|
297
316
|
) from exc
|
|
298
317
|
if not working_directory.is_dir():
|
|
299
|
-
raise RunnerContractError(
|
|
318
|
+
raise RunnerContractError(
|
|
319
|
+
"OpenCode request cwd must be an existing directory"
|
|
320
|
+
)
|
|
300
321
|
if request.mode == "apply":
|
|
301
322
|
self._require_staging_directory(context, working_directory)
|
|
302
323
|
|
|
@@ -311,12 +332,17 @@ class OpenCodeRunner:
|
|
|
311
332
|
working_directory if request.project_access == "project" else None
|
|
312
333
|
),
|
|
313
334
|
)
|
|
314
|
-
executable =
|
|
335
|
+
executable = (
|
|
336
|
+
getattr(context, "opencode_executable", None)
|
|
337
|
+
or resolve_opencode_executable()
|
|
338
|
+
)
|
|
315
339
|
isolated_env = opencode_isolation_env(context, security_config, request.stage)
|
|
316
340
|
attachment_dir: pathlib.Path | None = None
|
|
317
341
|
session_id: str | None = None
|
|
318
342
|
try:
|
|
319
|
-
attachments, attachment_dir = self._materialize_attachments(
|
|
343
|
+
attachments, attachment_dir = self._materialize_attachments(
|
|
344
|
+
context, request
|
|
345
|
+
)
|
|
320
346
|
# Every logical artifact is materialized, including pathless
|
|
321
347
|
# contract artifacts. Therefore native attachments carry the
|
|
322
348
|
# complete snapshot and the instruction is never duplicated in a
|
|
@@ -351,14 +377,18 @@ class OpenCodeRunner:
|
|
|
351
377
|
|
|
352
378
|
try:
|
|
353
379
|
if working_directory is None:
|
|
354
|
-
with tempfile.TemporaryDirectory(
|
|
380
|
+
with tempfile.TemporaryDirectory(
|
|
381
|
+
prefix="ai-push-hooks-readonly-"
|
|
382
|
+
) as directory:
|
|
355
383
|
completed = invoke(pathlib.Path(directory).resolve(strict=True))
|
|
356
384
|
else:
|
|
357
385
|
completed = invoke(working_directory)
|
|
358
386
|
except (RunnerError,) as error:
|
|
359
387
|
process_result = getattr(error, "_process_result", None)
|
|
360
388
|
partial_stdout = (
|
|
361
|
-
process_result.stdout
|
|
389
|
+
process_result.stdout
|
|
390
|
+
if isinstance(process_result, ProcessResult)
|
|
391
|
+
else ""
|
|
362
392
|
)
|
|
363
393
|
setattr(
|
|
364
394
|
error,
|
|
@@ -397,7 +427,9 @@ class OpenCodeRunner:
|
|
|
397
427
|
stdout=completed.stdout,
|
|
398
428
|
stderr=completed.stderr,
|
|
399
429
|
session=(
|
|
400
|
-
SessionMetadata(
|
|
430
|
+
SessionMetadata(
|
|
431
|
+
session_id=session_id, state="persisted", resumable=True
|
|
432
|
+
)
|
|
401
433
|
if session_id
|
|
402
434
|
else SessionMetadata()
|
|
403
435
|
),
|
|
@@ -427,7 +459,9 @@ class OpenCodeRunner:
|
|
|
427
459
|
non_agent_opencode_config(),
|
|
428
460
|
request.stage,
|
|
429
461
|
)
|
|
430
|
-
with tempfile.TemporaryDirectory(
|
|
462
|
+
with tempfile.TemporaryDirectory(
|
|
463
|
+
prefix=f"ai-push-hooks-session-{action}-"
|
|
464
|
+
) as directory:
|
|
431
465
|
return run_process(
|
|
432
466
|
argv,
|
|
433
467
|
cwd=pathlib.Path(directory).resolve(strict=True),
|
|
@@ -456,7 +490,10 @@ class OpenCodeRunner:
|
|
|
456
490
|
"OpenCode transcript path",
|
|
457
491
|
)
|
|
458
492
|
try:
|
|
459
|
-
executable =
|
|
493
|
+
executable = (
|
|
494
|
+
getattr(context, "opencode_executable", None)
|
|
495
|
+
or resolve_opencode_executable()
|
|
496
|
+
)
|
|
460
497
|
exported = self._lifecycle_process(
|
|
461
498
|
request,
|
|
462
499
|
context,
|
|
@@ -469,10 +506,17 @@ class OpenCodeRunner:
|
|
|
469
506
|
and not getattr(exported, "stderr_truncated", False)
|
|
470
507
|
and exported.stdout.strip()
|
|
471
508
|
):
|
|
472
|
-
write_text_no_follow(
|
|
509
|
+
write_text_no_follow(
|
|
510
|
+
transcript_path, exported.stdout.strip() + "\n"
|
|
511
|
+
)
|
|
473
512
|
else:
|
|
474
513
|
transcript_path = None
|
|
475
|
-
self._warn_export(
|
|
514
|
+
self._warn_export(
|
|
515
|
+
context,
|
|
516
|
+
request,
|
|
517
|
+
session_id,
|
|
518
|
+
"export returned no transcript",
|
|
519
|
+
)
|
|
476
520
|
except Exception as exc: # noqa: BLE001
|
|
477
521
|
transcript_path = None
|
|
478
522
|
self._warn_export(context, request, session_id, type(exc).__name__)
|
|
@@ -480,7 +524,10 @@ class OpenCodeRunner:
|
|
|
480
524
|
deleted = False
|
|
481
525
|
if getattr(context.config.llm, "delete_session_after_run", False):
|
|
482
526
|
try:
|
|
483
|
-
executable =
|
|
527
|
+
executable = (
|
|
528
|
+
getattr(context, "opencode_executable", None)
|
|
529
|
+
or resolve_opencode_executable()
|
|
530
|
+
)
|
|
484
531
|
deleted_result = self._lifecycle_process(
|
|
485
532
|
request,
|
|
486
533
|
context,
|
|
@@ -489,9 +536,13 @@ class OpenCodeRunner:
|
|
|
489
536
|
)
|
|
490
537
|
deleted = deleted_result.returncode == 0
|
|
491
538
|
if not deleted:
|
|
492
|
-
self._warn_export(
|
|
539
|
+
self._warn_export(
|
|
540
|
+
context, request, session_id, "session deletion failed"
|
|
541
|
+
)
|
|
493
542
|
except Exception as exc: # noqa: BLE001
|
|
494
|
-
self._warn_export(
|
|
543
|
+
self._warn_export(
|
|
544
|
+
context, request, session_id, f"delete {type(exc).__name__}"
|
|
545
|
+
)
|
|
495
546
|
|
|
496
547
|
state = "deleted" if deleted else "persisted"
|
|
497
548
|
finalized_session = SessionMetadata(
|
|
@@ -510,7 +561,9 @@ class OpenCodeRunner:
|
|
|
510
561
|
)
|
|
511
562
|
|
|
512
563
|
@staticmethod
|
|
513
|
-
def _warn_export(
|
|
564
|
+
def _warn_export(
|
|
565
|
+
context: Any, request: RunnerRequest, session_id: str, reason: str
|
|
566
|
+
) -> None:
|
|
514
567
|
logger = getattr(context, "logger", None)
|
|
515
568
|
if logger is not None:
|
|
516
569
|
logger.warn(
|
|
@@ -71,7 +71,9 @@ def opencode_isolation_env(
|
|
|
71
71
|
context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
|
|
72
72
|
)
|
|
73
73
|
if path_has_symlink(context.run_dir, lexical_isolation_root):
|
|
74
|
-
raise HookError(
|
|
74
|
+
raise HookError(
|
|
75
|
+
f"OpenCode isolation directory must not traverse a symlink: {stage_name}"
|
|
76
|
+
)
|
|
75
77
|
isolation_root = resolve_contained_path(
|
|
76
78
|
context.run_dir,
|
|
77
79
|
f"opencode-isolation/{sanitize_filename_component(stage_name)}",
|
|
@@ -184,7 +186,9 @@ def build_opencode_security_config(
|
|
|
184
186
|
description = "Read-only ai-push-hooks analysis agent"
|
|
185
187
|
elif agent_policy == "apply":
|
|
186
188
|
if not allow_paths:
|
|
187
|
-
raise HookError(
|
|
189
|
+
raise HookError(
|
|
190
|
+
"OpenCode apply agent requires an explicit non-empty allow_paths"
|
|
191
|
+
)
|
|
188
192
|
agent_name = OPENCODE_APPLY_AGENT
|
|
189
193
|
description = "Path-restricted ai-push-hooks apply agent"
|
|
190
194
|
permissions["read"] = "allow"
|
|
@@ -266,11 +270,17 @@ def validate_hook_owned_artifacts(
|
|
|
266
270
|
for file_path in files:
|
|
267
271
|
lexical_path = pathlib.Path(os.path.abspath(file_path))
|
|
268
272
|
if not is_path_within(lexical_path, run_root):
|
|
269
|
-
raise HookError(
|
|
273
|
+
raise HookError(
|
|
274
|
+
f"OpenCode attachment is not a hook-owned artifact: {file_path}"
|
|
275
|
+
)
|
|
270
276
|
if path_has_symlink(run_root, lexical_path):
|
|
271
|
-
raise HookError(
|
|
277
|
+
raise HookError(
|
|
278
|
+
f"OpenCode attachment must not traverse a symlink: {file_path}"
|
|
279
|
+
)
|
|
272
280
|
resolved_path = lexical_path.resolve(strict=True)
|
|
273
281
|
if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
|
|
274
|
-
raise HookError(
|
|
282
|
+
raise HookError(
|
|
283
|
+
f"OpenCode attachment must be a regular hook-owned file: {file_path}"
|
|
284
|
+
)
|
|
275
285
|
validated.append(resolved_path)
|
|
276
286
|
return validated
|
|
@@ -226,8 +226,13 @@ def run_process(
|
|
|
226
226
|
timeout and signal termination are raised as distinct fail-closed errors.
|
|
227
227
|
"""
|
|
228
228
|
|
|
229
|
-
if
|
|
230
|
-
not isinstance(
|
|
229
|
+
if (
|
|
230
|
+
not isinstance(argv, (tuple, list))
|
|
231
|
+
or not argv
|
|
232
|
+
or any(
|
|
233
|
+
not isinstance(argument, str) or not argument or "\x00" in argument
|
|
234
|
+
for argument in argv
|
|
235
|
+
)
|
|
231
236
|
):
|
|
232
237
|
raise RunnerError("runner command must be a non-empty NUL-free argv vector")
|
|
233
238
|
if not isinstance(cwd, pathlib.Path):
|
|
@@ -261,7 +266,9 @@ def run_process(
|
|
|
261
266
|
if not isinstance(input_path, pathlib.Path):
|
|
262
267
|
input_path = pathlib.Path(input_path)
|
|
263
268
|
if path_is_link_or_reparse(input_path):
|
|
264
|
-
raise RunnerError(
|
|
269
|
+
raise RunnerError(
|
|
270
|
+
"runner input file must not be a symlink or reparse point"
|
|
271
|
+
)
|
|
265
272
|
descriptor = -1
|
|
266
273
|
try:
|
|
267
274
|
# O_NONBLOCK prevents opening a FIFO from waiting for a writer;
|
|
@@ -312,7 +319,9 @@ def run_process(
|
|
|
312
319
|
except OSError as exc:
|
|
313
320
|
if input_file is not None:
|
|
314
321
|
input_file.close()
|
|
315
|
-
raise RunnerError(
|
|
322
|
+
raise RunnerError(
|
|
323
|
+
"runner process could not be started", details=type(exc).__name__
|
|
324
|
+
) from exc
|
|
316
325
|
|
|
317
326
|
if process.stdout is None or process.stderr is None or process.stdin is None:
|
|
318
327
|
_stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
|
|
@@ -354,7 +363,11 @@ def run_process(
|
|
|
354
363
|
stdout_thread.start()
|
|
355
364
|
stderr_thread.start()
|
|
356
365
|
|
|
357
|
-
input_bytes =
|
|
366
|
+
input_bytes = (
|
|
367
|
+
None
|
|
368
|
+
if input_text is None
|
|
369
|
+
else input_text.encode("utf-8", errors="surrogateescape")
|
|
370
|
+
)
|
|
358
371
|
|
|
359
372
|
def write_input() -> None:
|
|
360
373
|
try:
|
|
@@ -427,9 +440,15 @@ def run_process(
|
|
|
427
440
|
)
|
|
428
441
|
|
|
429
442
|
if returncode is None:
|
|
430
|
-
returncode =
|
|
443
|
+
returncode = (
|
|
444
|
+
process_returncode[0]
|
|
445
|
+
if process_returncode[0] is not None
|
|
446
|
+
else process.poll()
|
|
447
|
+
)
|
|
431
448
|
if timeout_cause is not None:
|
|
432
|
-
timeout_returncode =
|
|
449
|
+
timeout_returncode = (
|
|
450
|
+
returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
|
|
451
|
+
)
|
|
433
452
|
process_result = _captured_result(
|
|
434
453
|
timeout_returncode,
|
|
435
454
|
stdout,
|
|
@@ -53,7 +53,9 @@ class LazyRunnerSpec:
|
|
|
53
53
|
details=type(exc).__name__,
|
|
54
54
|
) from exc
|
|
55
55
|
if not callable(getattr(runner, "run", None)):
|
|
56
|
-
raise RunnerAdapterUnavailableError(
|
|
56
|
+
raise RunnerAdapterUnavailableError(
|
|
57
|
+
"selected runner does not implement run(request)"
|
|
58
|
+
)
|
|
57
59
|
return runner
|
|
58
60
|
|
|
59
61
|
|
|
@@ -68,15 +70,20 @@ _DEFAULT_SPECS = {
|
|
|
68
70
|
class RunnerRegistry:
|
|
69
71
|
"""A fixed registry; it intentionally has no runtime plugin registration API."""
|
|
70
72
|
|
|
71
|
-
def __init__(
|
|
73
|
+
def __init__(
|
|
74
|
+
self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None
|
|
75
|
+
) -> None:
|
|
72
76
|
selected = _DEFAULT_SPECS if specs is None else dict(specs)
|
|
73
77
|
unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
|
|
74
78
|
if unknown:
|
|
75
79
|
raise RunnerContractError(
|
|
76
|
-
"runner registry contains unsupported adapter types: "
|
|
80
|
+
"runner registry contains unsupported adapter types: "
|
|
81
|
+
+ ", ".join(sorted(unknown))
|
|
77
82
|
)
|
|
78
83
|
if set(selected) != set(KNOWN_RUNNER_TYPES):
|
|
79
|
-
raise RunnerContractError(
|
|
84
|
+
raise RunnerContractError(
|
|
85
|
+
"runner registry must contain all four known adapter types"
|
|
86
|
+
)
|
|
80
87
|
self._specs = dict(selected)
|
|
81
88
|
self._loaded: dict[str, Runner] = {}
|
|
82
89
|
self._load_locks = {
|
|
@@ -111,7 +118,9 @@ class RunnerRegistry:
|
|
|
111
118
|
DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
|
|
112
119
|
|
|
113
120
|
|
|
114
|
-
def get_runner(
|
|
121
|
+
def get_runner(
|
|
122
|
+
runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY
|
|
123
|
+
) -> Runner:
|
|
115
124
|
"""Resolve one of the four static adapter types lazily."""
|
|
116
125
|
|
|
117
126
|
return registry.get(runner_type)
|