ai-push-hooks 0.2.0 → 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,499 @@
1
+ """OpenCode adapter for the internal runner boundary.
2
+
3
+ The compatibility invocation deliberately remains the shipped ``opencode
4
+ run`` shape. This module owns the adapter-level protocol and lifecycle
5
+ semantics; workflow retries and JSON/schema handling remain in orchestration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import pathlib
12
+ import shutil
13
+ import sys
14
+ import tempfile
15
+ from typing import Any
16
+
17
+ from ..ask import (
18
+ OPENCODE_APPLY_AGENT,
19
+ OPENCODE_READ_ONLY_AGENT,
20
+ _transcript_dir,
21
+ build_opencode_security_config,
22
+ non_agent_opencode_config,
23
+ opencode_isolation_env,
24
+ resolve_opencode_executable,
25
+ sanitize_filename_component,
26
+ validate_opencode_attachments,
27
+ )
28
+ from ...paths import resolve_contained_path, write_text_no_follow
29
+ from ...paths import ensure_private_directory
30
+ from .process import ProcessResult
31
+ from .contracts import (
32
+ RunnerCapabilities,
33
+ RunnerContractError,
34
+ RunnerError,
35
+ RunnerMissingOutputError,
36
+ RunnerProtocolError,
37
+ RunnerRequest,
38
+ RunnerResult,
39
+ SessionMetadata,
40
+ bounded_diagnostic,
41
+ require_final_text,
42
+ require_zero_exit,
43
+ )
44
+ from .process import run_process
45
+
46
+
47
+ class OpenCodeRunner:
48
+ """Run one isolated OpenCode invocation and optionally finalize its session."""
49
+
50
+ capabilities = RunnerCapabilities(
51
+ supports_resume=True,
52
+ supports_finalize=True,
53
+ supports_transcript=True,
54
+ )
55
+
56
+ def _context(self, request: RunnerRequest) -> Any:
57
+ context = request.integration_context
58
+ if context is None:
59
+ raise RunnerContractError(
60
+ "OpenCode runner requires an integration context for executable and isolation policy"
61
+ )
62
+ return context
63
+
64
+ @staticmethod
65
+ def _parse_output(raw: str, request: RunnerRequest) -> tuple[str | None, str]:
66
+ session_id: str | None = None
67
+ text_parts: list[str] = []
68
+
69
+ def protocol_failure(message: str, *, missing: bool = False) -> RunnerProtocolError:
70
+ error: RunnerProtocolError
71
+ if missing:
72
+ error = RunnerMissingOutputError(message)
73
+ else:
74
+ error = RunnerProtocolError(message)
75
+ # A session may have been announced before a later malformed or
76
+ # error event. Keep that identity on every protocol failure so
77
+ # the orchestrator can still finalize it.
78
+ known_session = session_id
79
+ if known_session is None and request.resume_session:
80
+ known_session = request.session_id
81
+ setattr(error, "session_id", known_session)
82
+ return error
83
+
84
+ for line in raw.splitlines():
85
+ if not line.strip():
86
+ continue
87
+ try:
88
+ event = json.loads(line)
89
+ except json.JSONDecodeError as exc:
90
+ raise protocol_failure("OpenCode emitted malformed JSONL output") from exc
91
+ if not isinstance(event, dict):
92
+ raise protocol_failure("OpenCode emitted a non-object JSONL event")
93
+
94
+ event_type = event.get("type")
95
+
96
+ for key in ("sessionID", "session_id"):
97
+ value = event.get(key)
98
+ if session_id is None and isinstance(value, str) and value.strip():
99
+ session_id = value.strip()
100
+
101
+ if event_type in {"error", "fatal", "session.error"} or (
102
+ isinstance(event.get("error"), (str, dict, list))
103
+ and event_type not in {"text", "step_finish"}
104
+ ):
105
+ # Do not serialize the provider event: OpenCode may echo the
106
+ # instruction, artifact bodies, or credential-shaped data.
107
+ raise protocol_failure("OpenCode reported an error event")
108
+
109
+ if event_type != "text":
110
+ # New OpenCode event kinds are additive unless they are an
111
+ # explicit error. Only text parts form the final response.
112
+ continue
113
+ part = event.get("part")
114
+ if not isinstance(part, dict):
115
+ raise protocol_failure("OpenCode text event has no text part")
116
+ text = part.get("text")
117
+ if not isinstance(text, str):
118
+ raise protocol_failure("OpenCode text event has an invalid text value")
119
+ text_parts.append(text)
120
+
121
+ final_text = "\n".join(text_parts).strip()
122
+ if not final_text:
123
+ raise protocol_failure("OpenCode produced no final response", missing=True)
124
+ return session_id, final_text
125
+
126
+ @staticmethod
127
+ def _session_id_from_partial_output(raw: str) -> str | None:
128
+ """Recover only a complete session identifier from a bounded prefix."""
129
+
130
+ for line in raw.splitlines():
131
+ try:
132
+ event = json.loads(line)
133
+ except json.JSONDecodeError:
134
+ continue
135
+ if not isinstance(event, dict):
136
+ continue
137
+ for key in ("sessionID", "session_id"):
138
+ value = event.get(key)
139
+ if isinstance(value, str) and value.strip():
140
+ return value.strip()
141
+ return None
142
+
143
+ @staticmethod
144
+ def _attachment_name(index: int, logical_name: str) -> str:
145
+ # The order prefix prevents collisions after sanitizing path-like
146
+ # logical names while retaining a useful basename for CLI diagnostics.
147
+ basename = pathlib.PurePath(logical_name.replace("\\", "/")).name
148
+ safe_basename = sanitize_filename_component(basename)
149
+ if safe_basename in {"", ".", ".."}:
150
+ safe_basename = "artifact"
151
+ return f"{index:04d}-{safe_basename}"
152
+
153
+ def _materialize_attachments(
154
+ self,
155
+ context: Any,
156
+ request: RunnerRequest,
157
+ ) -> tuple[list[pathlib.Path], pathlib.Path | None]:
158
+ original_paths = [artifact.path for artifact in request.artifacts]
159
+ # Validate source ownership and symlink traversal before making any
160
+ # copy. The source is then never reopened: its logical snapshot is
161
+ # the only content sent through OpenCode's native --file transport.
162
+ validate_opencode_attachments(
163
+ context,
164
+ [path for path in original_paths if path is not None],
165
+ )
166
+ if not request.artifacts:
167
+ return [], None
168
+
169
+ run_root = ensure_private_directory(context.run_dir.resolve(strict=True))
170
+ attachment_dir = pathlib.Path(
171
+ tempfile.mkdtemp(prefix="opencode-attachments-", dir=str(run_root))
172
+ )
173
+ try:
174
+ ensure_private_directory(attachment_dir, private_root=run_root)
175
+ paths: list[pathlib.Path] = []
176
+ for index, artifact in enumerate(request.artifacts, start=1):
177
+ target = resolve_contained_path(
178
+ attachment_dir,
179
+ self._attachment_name(index, artifact.name),
180
+ "OpenCode materialized attachment path",
181
+ )
182
+ write_text_no_follow(target, artifact.content)
183
+ paths.append(target)
184
+ return paths, attachment_dir
185
+ except Exception:
186
+ shutil.rmtree(attachment_dir, ignore_errors=True)
187
+ raise
188
+
189
+ @staticmethod
190
+ def _cleanup_attachments(
191
+ attachment_dir: pathlib.Path | None,
192
+ *,
193
+ session_id: str | None = None,
194
+ ) -> None:
195
+ if attachment_dir is None:
196
+ return
197
+ # Capture the active exception before entering the cleanup try block;
198
+ # sys.exc_info() inside ``except OSError`` refers to the cleanup error,
199
+ # not the invocation error being unwound.
200
+ unwinding_error = sys.exc_info()[1]
201
+ try:
202
+ shutil.rmtree(attachment_dir)
203
+ except FileNotFoundError:
204
+ return
205
+ except OSError as exc:
206
+ if unwinding_error is not None:
207
+ if session_id and not getattr(unwinding_error, "session_id", None):
208
+ setattr(unwinding_error, "session_id", session_id)
209
+ return
210
+ error = RunnerError("OpenCode attachment cleanup failed")
211
+ if session_id:
212
+ setattr(error, "session_id", session_id)
213
+ raise error from exc
214
+
215
+ def _argv(
216
+ self,
217
+ request: RunnerRequest,
218
+ context: Any,
219
+ executable: str,
220
+ attachments: list[pathlib.Path],
221
+ *,
222
+ prompt: str,
223
+ ) -> list[str]:
224
+ agent = OPENCODE_APPLY_AGENT if request.mode == "apply" else OPENCODE_READ_ONLY_AGENT
225
+ config = getattr(getattr(context, "config", None), "llm", None)
226
+ title_prefix = getattr(config, "session_title_prefix", "ai-push-hooks")
227
+ argv = [
228
+ executable,
229
+ "run",
230
+ "--agent",
231
+ agent,
232
+ "--pure",
233
+ "--format",
234
+ "json",
235
+ ]
236
+ if request.model:
237
+ argv.extend(["--model", request.model])
238
+ if request.variant:
239
+ argv.extend(["--variant", request.variant])
240
+ if request.resume_session and request.session_id:
241
+ argv.extend(["--session", request.session_id])
242
+ else:
243
+ argv.extend(
244
+ [
245
+ "--title",
246
+ f"{title_prefix} {context.run_id} {request.stage}",
247
+ ]
248
+ )
249
+ for path in attachments:
250
+ argv.extend(["--file", str(path)])
251
+ argv.extend(["--", prompt])
252
+ return argv
253
+
254
+ def run(self, request: RunnerRequest) -> RunnerResult:
255
+ if request.runner_type != "opencode":
256
+ raise RunnerContractError(
257
+ f"OpenCode runner cannot handle runner type {request.runner_type!r}"
258
+ )
259
+ context = self._context(request)
260
+ if request.mode == "apply" and not request.allow_paths:
261
+ raise RunnerContractError("OpenCode apply requests require allow_paths")
262
+ if request.mode == "apply" and request.project_access == "artifacts":
263
+ # Apply always runs in a host-created staging projection, even for
264
+ # the compatibility profile.
265
+ working_directory = request.cwd
266
+ elif request.project_access == "project":
267
+ working_directory = request.cwd
268
+ else:
269
+ working_directory = None
270
+
271
+ if working_directory is not None and not working_directory.is_dir():
272
+ raise RunnerContractError("OpenCode request cwd must be an existing directory")
273
+
274
+ agent = "apply" if request.mode == "apply" else "read-only"
275
+ _agent_name, security_config = build_opencode_security_config(
276
+ agent,
277
+ request.allow_paths,
278
+ non_vcs_working_directory=(
279
+ working_directory if request.mode == "apply" else None
280
+ ),
281
+ project_read_root=(
282
+ working_directory if request.project_access == "project" else None
283
+ ),
284
+ )
285
+ executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
286
+ isolated_env = opencode_isolation_env(context, security_config, request.stage)
287
+ attachment_dir: pathlib.Path | None = None
288
+ session_id: str | None = None
289
+ try:
290
+ attachments, attachment_dir = self._materialize_attachments(context, request)
291
+ # Every logical artifact is materialized, including pathless
292
+ # contract artifacts. Therefore native attachments carry the
293
+ # complete snapshot and the instruction is never duplicated in a
294
+ # prompt packet.
295
+ argv = self._argv(
296
+ request,
297
+ context,
298
+ executable,
299
+ attachments,
300
+ prompt=request.instruction,
301
+ )
302
+
303
+ def invoke(cwd: pathlib.Path) -> Any:
304
+ completed = run_process(
305
+ argv,
306
+ cwd=cwd,
307
+ timeout_seconds=request.timeout_seconds,
308
+ env=isolated_env,
309
+ )
310
+ if getattr(completed, "stdout_truncated", False) or getattr(
311
+ completed, "stderr_truncated", False
312
+ ):
313
+ error = RunnerProtocolError("OpenCode process output was truncated")
314
+ setattr(
315
+ error,
316
+ "session_id",
317
+ self._session_id_from_partial_output(completed.stdout)
318
+ or (request.session_id if request.resume_session else None),
319
+ )
320
+ raise error
321
+ return completed
322
+
323
+ try:
324
+ if working_directory is None:
325
+ with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
326
+ completed = invoke(pathlib.Path(directory).resolve(strict=True))
327
+ else:
328
+ completed = invoke(working_directory.resolve(strict=True))
329
+ except (RunnerError,) as error:
330
+ process_result = getattr(error, "_process_result", None)
331
+ partial_stdout = (
332
+ process_result.stdout if isinstance(process_result, ProcessResult) else ""
333
+ )
334
+ setattr(
335
+ error,
336
+ "session_id",
337
+ self._session_id_from_partial_output(partial_stdout)
338
+ or getattr(error, "session_id", None)
339
+ or (request.session_id if request.resume_session else None),
340
+ )
341
+ raise
342
+
343
+ result = RunnerResult(
344
+ final_text="",
345
+ returncode=completed.returncode,
346
+ stdout=completed.stdout,
347
+ stderr=completed.stderr,
348
+ )
349
+ # Check the process status before parsing. A failed child may emit
350
+ # a partial or malformed stream; the process failure is the
351
+ # truthful, normalized terminal diagnosis.
352
+ try:
353
+ require_zero_exit(request, result, env=isolated_env)
354
+ except RunnerError as error:
355
+ setattr(
356
+ error,
357
+ "session_id",
358
+ self._session_id_from_partial_output(completed.stdout)
359
+ or (request.session_id if request.resume_session else None),
360
+ )
361
+ raise
362
+ session_id, final_text = self._parse_output(completed.stdout, request)
363
+ if request.resume_session and session_id is None:
364
+ session_id = request.session_id
365
+ return RunnerResult(
366
+ final_text=require_final_text(final_text, mode=request.mode),
367
+ returncode=completed.returncode,
368
+ stdout=completed.stdout,
369
+ stderr=completed.stderr,
370
+ session=(
371
+ SessionMetadata(session_id=session_id, state="persisted", resumable=True)
372
+ if session_id
373
+ else SessionMetadata()
374
+ ),
375
+ )
376
+ finally:
377
+ active_error = sys.exc_info()[1]
378
+ cleanup_session_id = session_id or getattr(active_error, "session_id", None)
379
+ self._cleanup_attachments(
380
+ attachment_dir,
381
+ session_id=(
382
+ cleanup_session_id if isinstance(cleanup_session_id, str) else None
383
+ ),
384
+ )
385
+
386
+ def _lifecycle_process(
387
+ self,
388
+ request: RunnerRequest,
389
+ context: Any,
390
+ argv: list[str],
391
+ action: str,
392
+ ) -> Any:
393
+ # Export/delete must use the original stage isolation root. Using a
394
+ # different action-specific root makes retained sessions invisible to
395
+ # OpenCode because its HOME/XDG state is isolated per stage.
396
+ env = opencode_isolation_env(
397
+ context,
398
+ non_agent_opencode_config(),
399
+ request.stage,
400
+ )
401
+ with tempfile.TemporaryDirectory(prefix=f"ai-push-hooks-session-{action}-") as directory:
402
+ return run_process(
403
+ argv,
404
+ cwd=pathlib.Path(directory).resolve(strict=True),
405
+ timeout_seconds=request.timeout_seconds,
406
+ env=env,
407
+ )
408
+
409
+ def finalize(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
410
+ session = result.session
411
+ if session is None or not session.session_id:
412
+ return result
413
+ context = self._context(request)
414
+ session_id = session.session_id
415
+ transcript_path: pathlib.Path | None = None
416
+
417
+ if getattr(context.config.logging, "capture_llm_transcript", False):
418
+ transcript_dir = _transcript_dir(context)
419
+ if transcript_dir is not None:
420
+ transcript_path = resolve_contained_path(
421
+ transcript_dir,
422
+ (
423
+ f"{sanitize_filename_component(context.run_id)}-"
424
+ f"{sanitize_filename_component(request.stage)}-"
425
+ f"{sanitize_filename_component(session_id)}.json"
426
+ ),
427
+ "OpenCode transcript path",
428
+ )
429
+ try:
430
+ executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
431
+ exported = self._lifecycle_process(
432
+ request,
433
+ context,
434
+ [executable, "export", session_id, "--pure"],
435
+ "export",
436
+ )
437
+ if (
438
+ exported.returncode == 0
439
+ and not getattr(exported, "stdout_truncated", False)
440
+ and not getattr(exported, "stderr_truncated", False)
441
+ and exported.stdout.strip()
442
+ ):
443
+ write_text_no_follow(transcript_path, exported.stdout.strip() + "\n")
444
+ else:
445
+ transcript_path = None
446
+ self._warn_export(context, request, session_id, "export returned no transcript")
447
+ except Exception as exc: # noqa: BLE001
448
+ transcript_path = None
449
+ self._warn_export(context, request, session_id, type(exc).__name__)
450
+
451
+ deleted = False
452
+ if getattr(context.config.llm, "delete_session_after_run", False):
453
+ try:
454
+ executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
455
+ deleted_result = self._lifecycle_process(
456
+ request,
457
+ context,
458
+ [executable, "session", "delete", session_id, "--pure"],
459
+ "delete",
460
+ )
461
+ deleted = deleted_result.returncode == 0
462
+ if not deleted:
463
+ self._warn_export(context, request, session_id, "session deletion failed")
464
+ except Exception as exc: # noqa: BLE001
465
+ self._warn_export(context, request, session_id, f"delete {type(exc).__name__}")
466
+
467
+ state = "deleted" if deleted else "persisted"
468
+ finalized_session = SessionMetadata(
469
+ session_id=session_id,
470
+ state=state,
471
+ resumable=not deleted,
472
+ transcript=str(transcript_path) if transcript_path is not None else None,
473
+ )
474
+ return RunnerResult(
475
+ final_text=result.final_text,
476
+ returncode=result.returncode,
477
+ stdout=result.stdout,
478
+ stderr=result.stderr,
479
+ session=finalized_session,
480
+ transcript=result.transcript,
481
+ )
482
+
483
+ @staticmethod
484
+ def _warn_export(context: Any, request: RunnerRequest, session_id: str, reason: str) -> None:
485
+ logger = getattr(context, "logger", None)
486
+ if logger is not None:
487
+ logger.warn(
488
+ "llm.transcript_export_failed",
489
+ "Could not capture or delete the OpenCode session cleanly.",
490
+ stage_name=request.stage,
491
+ session_id=session_id,
492
+ reason=bounded_diagnostic(reason, max_chars=120),
493
+ )
494
+
495
+
496
+ def create_runner() -> OpenCodeRunner:
497
+ """Registry factory for the built-in OpenCode adapter."""
498
+
499
+ return OpenCodeRunner()