ai-push-hooks 0.3.0 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. package/run.sh +0 -29
@@ -54,15 +54,15 @@ def _safe_input_artifacts(
54
54
  ) -> tuple[RunnerArtifact, ...]:
55
55
  """Build the ordered logical artifact list shared by every adapter.
56
56
 
57
- ``validate_opencode_attachments`` is retained as the hook-owned artifact
58
- boundary used by the shipped implementation. The logical names are the
57
+ ``validate_hook_owned_artifacts`` is the hook-owned artifact boundary used
58
+ before adapter dispatch. The logical names are the
59
59
  configured input references, rather than filesystem basenames, so command,
60
60
  Codex, Claude, and OpenCode receive the same ordered context.
61
61
  """
62
62
 
63
- from .ask import validate_opencode_attachments
63
+ from .runners.opencode_support import validate_hook_owned_artifacts
64
64
 
65
- validated = validate_opencode_attachments(context, input_paths)
65
+ validated = validate_hook_owned_artifacts(context, input_paths)
66
66
  if len(validated) != len(step.inputs):
67
67
  raise HookError(
68
68
  f"Runner input count does not match configured inputs for step `{step.id}`"
@@ -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
- not is_path_within(lexical_path, run_root)
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(f"Unable to safely resolve runner artifact: {name}") from exc
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(f"Runner artifact must be a regular hook-owned file: {name}")
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 = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
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(f"Runner artifact must be a regular hook-owned file: {name}")
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(f"Unable to read hook-owned runner artifact: {name}") from exc
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(f"Runner working directory is not a directory: {working_directory}")
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(final_text="", returncode=1, stdout="", stderr="", session=session)
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 (result.session is not None and result.session.session_id) or not fallback_session_id:
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(context: RuntimeContext, invocation: _RunnerInvocation) -> None:
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 ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
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(reason: str, *, details: str = "") -> RunnerAdapterUnavailableError:
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(marker for marker in REQUIRED_HELP_MARKERS if marker not in help_text)
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("missing required flags or modes", details=", ".join(missing))
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(request, process_result, "stdout was not valid JSON") from exc
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(request, process_result, "top-level JSON value was not an object")
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(request, process_result, "top-level JSON type was not result")
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(request, process, "Codex JSONL turn order was invalid")
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(request, process, "Codex item.completed event had no item")
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(request, process, "Codex emitted an empty JSONL stream")
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 isinstance(value, str) and (allow_empty or bool(value.strip())) and not any(
29
- ord(character) < 32 for character in value
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("timeout_seconds must be finite and greater than zero")
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("model must be a non-empty NUL-free string when provided")
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(f"command[{index}] must be a non-empty NUL-free string")
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(f"unknown placeholder {placeholder!r} in command[{index}]")
92
- if ("{" in argument or "}" in argument) and argument not in _ALLOWED_PLACEHOLDERS:
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("command must not contain {prompt} with stdin transport")
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("runner executable was not found") from exc
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,
@@ -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(ord(character) < 32 and character not in allowed_controls for character in value):
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("prompt packet artifacts must contain RunnerArtifact values")
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(self.instruction, "instruction", allow_empty=True, allow_line_breaks=True)
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("artifacts must contain RunnerArtifact values")
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(self.final_text, "final_text", allow_empty=True, allow_controls=True)
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(self.transcript, "transcript", allow_empty=True, allow_line_breaks=True)
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(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
353
- ...
363
+ def finalize(
364
+ self, request: RunnerRequest, result: RunnerResult
365
+ ) -> RunnerResult: ...
354
366
 
355
367
 
356
- def finalize_runner(runner: Runner, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
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 safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)] + DIAGNOSTIC_TRUNCATION_MARKER
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(f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}")
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 = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
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 = request_sensitive_diagnostics(
586
- request,
587
- result.stdout,
588
- result.stderr,
589
- max_chars=diagnostic_limit,
590
- env=env,
591
- extra=secrets,
592
- ) or f"exit code {result.returncode}"
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,