agentic-runner 2.6.0__py3-none-any.whl

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 (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,455 @@
1
+ """Runner Hooks: operator executables in platform-named slots (ADR-0013 §10, ticket 26 §1).
2
+
3
+ The Buildkite shape, narrowed. An operator drops an executable named exactly as a
4
+ :class:`HookName` into the hooks path and the Runner runs it at that point of a Directive
5
+ attempt; nobody adds a slot, and there are no repository or plugin hooks — a repository
6
+ hook is code the Agent itself can author, which the Runner would then execute *outside*
7
+ the Agent Runtime Profile's command policy (ADR-0011 §12), and MCP is the extension point
8
+ instead of plugins (map ticket 05).
9
+
10
+ Two invariants this module exists to hold:
11
+
12
+ * **A hook sees no credential value** (map ticket 17 A10). Values reach verb seams and
13
+ Runner-hosted MCP servers only. The environment here is built from an allow-list of
14
+ platform ids and enums plus the attempt's own callback socket — never the parent
15
+ process environment, which on a Runner pod carries the GitHub App key and the internal
16
+ service token. A hook that needs a credential is map fog; there is deliberately no path
17
+ for it.
18
+ * **A hook runs as the Contract's uid, in the Workspace** (ADR-0015 §1). It is repository-
19
+ adjacent code like the Directive and the Verifier, so it runs under the same floor:
20
+ same uid, same rlimits, same ``TMPDIR`` inside the Contract's 0700 tree.
21
+
22
+ The ``environment`` hook is **not** the secrets hook (ADR-0011 §9). Everything it exports
23
+ reaches the Agent Runtime subprocess, which is the one process on the box that must hold
24
+ no org credential — which inverts the purpose Buildkite documents for the same slot.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import contextlib
31
+ import os
32
+ import re
33
+ import time
34
+ from collections.abc import Awaitable, Callable, Mapping
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from types import TracebackType
38
+ from typing import Any, Final, Self
39
+
40
+ from agentic_runner.workers._runtime_support import (
41
+ RESERVED_DIRECTIVE_ENV,
42
+ run_subprocess_exec,
43
+ )
44
+ from agentic_runner.workers.contract_isolation import DirectiveSandbox
45
+ from agentic_runner_contracts.public_metadata import (
46
+ DIRECTIVE_HOOK_ORDER,
47
+ FATAL_HOOKS,
48
+ HookName,
49
+ hook_name,
50
+ )
51
+ from agentic_runner_contracts.redaction import redact_secret_like_text
52
+
53
+ __all__ = [
54
+ "DIRECTIVE_HOOK_ORDER",
55
+ "DIRECTIVE_REJECTED_EVIDENCE_SOURCE",
56
+ "HOOK_EVIDENCE_SOURCE",
57
+ "RESERVED_DIRECTIVE_ENV",
58
+ "AttemptFacts",
59
+ "DirectiveHookSession",
60
+ "DirectiveRejectedError",
61
+ "HookName",
62
+ "HookRefusedError",
63
+ "HookRun",
64
+ "HookRunner",
65
+ "load_hooks",
66
+ "prepare_attempt_dir",
67
+ ]
68
+
69
+ HOOK_EVIDENCE_SOURCE: Final[str] = "runner.hook"
70
+ DIRECTIVE_REJECTED_EVIDENCE_SOURCE: Final[str] = "runner.directive_rejected"
71
+
72
+ # What the Runner tells a hook about the attempt: platform ids and enums, never Directive
73
+ # text (map ticket 07 — a hook name and a hook's environment are as displayed as a queue
74
+ # name). The prompt, the repository and the branch are deliberately absent.
75
+ _FACT_ENV_PREFIX: Final[str] = "AGENTIC_RUNNER_"
76
+ # Where the `environment` hook writes its exports (`NAME=value`, one per line), and where
77
+ # `pre_directive` reads the proposed Agent Runtime environment as *names only* — the v4
78
+ # Buildkite shape, so the reject gate can refuse on what will be set without being handed
79
+ # the values.
80
+ ENV_FILE_ENV: Final[str] = f"{_FACT_ENV_PREFIX}ENV_FILE"
81
+ PROPOSED_ENV_FILE_ENV: Final[str] = f"{_FACT_ENV_PREFIX}PROPOSED_ENV_FILE"
82
+
83
+ _ENV_NAME_RE: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
84
+ _HOOK_TIMEOUT_SECONDS: Final[int] = 300
85
+ _HOOK_OUTPUT_LIMIT_BYTES: Final[int] = 16_384
86
+ # A hook that cannot be executed (a ConfigMap mounted without `defaultMode: 0755` is the
87
+ # way this happens in practice) is a failed hook, not a crashed Runner: 126 is the shell's
88
+ # own "found but not executable" so the Evidence reads the way an operator expects.
89
+ _NOT_EXECUTABLE_EXIT_CODE: Final[int] = 126
90
+ _TIMEOUT_EXIT_CODE: Final[int] = 124
91
+ _DIR_MODE: Final[int] = 0o700
92
+
93
+ AppendEvidence = Callable[[str, Mapping[str, object]], Awaitable[None]]
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class AttemptFacts:
98
+ """What one Directive attempt is, in platform vocabulary a hook may be told."""
99
+
100
+ # All defaulted: `runner_startup` and `runner_shutdown` run outside any Directive,
101
+ # so there is no Work Record, Contract or Agent to name — and an empty value is
102
+ # omitted from the environment rather than exported blank.
103
+ work_record_id: str = ""
104
+ directive_id: str = ""
105
+ contract_id: str = ""
106
+ agent_id: str = ""
107
+ persona: str = ""
108
+ runtime_kind: str = ""
109
+
110
+ def env(self) -> dict[str, str]:
111
+ return {
112
+ f"{_FACT_ENV_PREFIX}{key.upper()}": value
113
+ for key, value in (
114
+ ("work_record_id", self.work_record_id),
115
+ ("directive_id", self.directive_id),
116
+ ("contract_id", self.contract_id),
117
+ ("agent_id", self.agent_id),
118
+ ("persona", self.persona),
119
+ ("runtime_kind", self.runtime_kind),
120
+ )
121
+ if value
122
+ }
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class HookRun:
127
+ """One hook execution, in the shape the Evidence Event carries (ticket 26 §1)."""
128
+
129
+ name: HookName
130
+ exit_code: int
131
+ duration_ms: int
132
+ stdout: str
133
+
134
+ @property
135
+ def failed(self) -> bool:
136
+ return self.exit_code != 0
137
+
138
+ def evidence(self) -> dict[str, object]:
139
+ return {
140
+ "hook": self.name.value,
141
+ "exit_code": self.exit_code,
142
+ "duration_ms": self.duration_ms,
143
+ "stdout": self.stdout,
144
+ }
145
+
146
+
147
+ class HookRefusedError(RuntimeError):
148
+ """A hook at or before ``pre_runtime`` exited non-zero, so the attempt stops."""
149
+
150
+ def __init__(self, run: HookRun) -> None:
151
+ super().__init__(f"{run.name.value} hook exited {run.exit_code}")
152
+ self.run = run
153
+
154
+
155
+ class DirectiveRejectedError(HookRefusedError):
156
+ """``pre_directive`` refused this Directive before anything ran (the reject gate)."""
157
+
158
+
159
+ def load_hooks(hooks_path: Path | None) -> dict[HookName, Path]:
160
+ """Every installed hook, keyed by its slot.
161
+
162
+ Refuses a file whose name is not in the catalogue rather than ignoring it: an
163
+ operator who writes ``pre-directive`` or ``post_command`` has installed a reject gate
164
+ that would never run, and a security control that silently does nothing is worse than
165
+ a Runner that will not start. Dotfiles are skipped (editor swap files, `..data` from
166
+ a Kubernetes ConfigMap projection) and so are subdirectories.
167
+ """
168
+
169
+ if hooks_path is None or not hooks_path.is_dir():
170
+ return {}
171
+ installed: dict[HookName, Path] = {}
172
+ for entry in sorted(hooks_path.iterdir()):
173
+ if entry.name.startswith(".") or not entry.is_file():
174
+ continue
175
+ installed[hook_name(entry.name)] = entry
176
+ return installed
177
+
178
+
179
+ def prepare_attempt_dir(path: Path, uid: int | None) -> Path:
180
+ """A 0700 directory the Contract's uid owns — the attempt's own scratch."""
181
+
182
+ path.mkdir(parents=True, exist_ok=True)
183
+ path.chmod(_DIR_MODE)
184
+ if uid is not None:
185
+ os.chown(path, uid, uid)
186
+ return path
187
+
188
+
189
+ class HookRunner:
190
+ """Loads the hooks path once and runs one slot at a time under the Contract's floor."""
191
+
192
+ def __init__(
193
+ self,
194
+ *,
195
+ hooks_path: Path | None = None,
196
+ hooks: Mapping[HookName, Path] | None = None,
197
+ spawn: Callable[..., Awaitable[Any]] = run_subprocess_exec,
198
+ timeout_seconds: int = _HOOK_TIMEOUT_SECONDS,
199
+ output_limit_bytes: int = _HOOK_OUTPUT_LIMIT_BYTES,
200
+ monotonic: Callable[[], float] = time.monotonic,
201
+ ) -> None:
202
+ self._hooks = dict(hooks) if hooks is not None else load_hooks(hooks_path)
203
+ self._spawn = spawn
204
+ self._timeout_seconds = timeout_seconds
205
+ self._output_limit_bytes = output_limit_bytes
206
+ self._monotonic = monotonic
207
+
208
+ @property
209
+ def installed(self) -> frozenset[HookName]:
210
+ return frozenset(self._hooks)
211
+
212
+ def has(self, name: HookName) -> bool:
213
+ return name in self._hooks
214
+
215
+ async def run(
216
+ self,
217
+ name: HookName,
218
+ *,
219
+ cwd: Path,
220
+ facts: AttemptFacts,
221
+ sandbox: DirectiveSandbox | None = None,
222
+ extra_env: Mapping[str, str] | None = None,
223
+ ) -> HookRun | None:
224
+ """Run one hook, or return None when that slot is empty."""
225
+
226
+ hook_path = self._hooks.get(name)
227
+ if hook_path is None:
228
+ return None
229
+ env = build_hook_env(
230
+ facts=facts,
231
+ hook=name,
232
+ hook_path=hook_path,
233
+ workspace_path=cwd,
234
+ sandbox=sandbox,
235
+ extra_env=extra_env,
236
+ )
237
+ started = self._monotonic()
238
+ try:
239
+ result = await self._spawn(
240
+ argv=[str(hook_path)],
241
+ cwd=cwd,
242
+ env=env,
243
+ stdin=None,
244
+ timeout_seconds=self._timeout_seconds,
245
+ output_limit_bytes=self._output_limit_bytes,
246
+ sandbox=sandbox,
247
+ )
248
+ exit_code = int(result.exit_code)
249
+ output = f"{result.stdout}{result.stderr}"
250
+ except TimeoutError:
251
+ exit_code, output = _TIMEOUT_EXIT_CODE, f"{name.value} hook timed out"
252
+ except OSError as error:
253
+ exit_code = _NOT_EXECUTABLE_EXIT_CODE
254
+ output = f"{name.value} hook could not be executed: {error.__class__.__name__}"
255
+ return HookRun(
256
+ name=name,
257
+ exit_code=exit_code,
258
+ # Scrubbed exactly like verifier diagnostics (map ticket 06 §4): a hook prints
259
+ # whatever it likes and this text lands in the control plane's Evidence.
260
+ stdout=redact_secret_like_text(output)[: self._output_limit_bytes],
261
+ duration_ms=int((self._monotonic() - started) * 1000),
262
+ )
263
+
264
+
265
+ def build_hook_env(
266
+ *,
267
+ facts: AttemptFacts,
268
+ hook: HookName,
269
+ hook_path: Path,
270
+ workspace_path: Path,
271
+ sandbox: DirectiveSandbox | None,
272
+ extra_env: Mapping[str, str] | None = None,
273
+ ) -> dict[str, str]:
274
+ """The allow-list environment a hook runs with — no credential value, ever.
275
+
276
+ Built from nothing but this attempt's platform ids, the paths the Runner owns and a
277
+ ``PATH``. The parent process environment is never inherited: on a Runner pod it holds
278
+ the GitHub App private key, the internal service token and the database URL, and
279
+ inheritance is how every one of those would reach an operator's shell script.
280
+ """
281
+
282
+ env = {
283
+ "PATH": os.environ.get("PATH") or os.defpath,
284
+ "HOME": str(sandbox.home_dir) if sandbox else os.environ.get("HOME", str(workspace_path)),
285
+ f"{_FACT_ENV_PREFIX}HOOK": hook.value,
286
+ f"{_FACT_ENV_PREFIX}HOOK_PATH": str(hook_path),
287
+ f"{_FACT_ENV_PREFIX}WORKSPACE": str(workspace_path),
288
+ **facts.env(),
289
+ }
290
+ if sandbox is not None:
291
+ env["TMPDIR"] = str(sandbox.tmp_dir)
292
+ env.update(extra_env or {})
293
+ return env
294
+
295
+
296
+ class DirectiveHookSession:
297
+ """The hook lifecycle of one Directive attempt, in catalogue order.
298
+
299
+ An async context manager because ``pre_exit`` is a *deferred teardown*: it runs on
300
+ every exit path — success, a refusal by an earlier hook, an exception, and
301
+ cancellation (a Temporal activity cancel, a worker drain) — which is the whole reason
302
+ the slot exists. Everything else is ``phase()`` calls the activity makes in order.
303
+ """
304
+
305
+ def __init__(
306
+ self,
307
+ *,
308
+ hooks: HookRunner,
309
+ facts: AttemptFacts,
310
+ workspace_path: Path,
311
+ append_evidence: AppendEvidence,
312
+ sandbox: DirectiveSandbox | None = None,
313
+ attempt_dir: Path | None = None,
314
+ proposed_env_names: tuple[str, ...] = (),
315
+ teardown_grace_seconds: float = 30.0,
316
+ ) -> None:
317
+ self._hooks = hooks
318
+ self._facts = facts
319
+ self._workspace_path = workspace_path
320
+ self._append_evidence = append_evidence
321
+ self._sandbox = sandbox
322
+ self._attempt_dir = attempt_dir or workspace_path
323
+ self._proposed_env_names = proposed_env_names
324
+ self._teardown_grace_seconds = teardown_grace_seconds
325
+ # What `pre_exit` is told about the attempt it is cleaning up after: non-zero if
326
+ # anything in it failed, whether or not that failure was fatal. Reported, never
327
+ # consulted — nothing downstream reads it, least of all the Verifier's verdict.
328
+ self._exit_code = 0
329
+
330
+ async def __aenter__(self) -> Self:
331
+ return self
332
+
333
+ async def __aexit__(
334
+ self,
335
+ exc_type: type[BaseException] | None,
336
+ exc: BaseException | None,
337
+ traceback: TracebackType | None,
338
+ ) -> None:
339
+ if exc is not None and self._exit_code == 0:
340
+ self._exit_code = 1
341
+ teardown = asyncio.ensure_future(self._run_pre_exit())
342
+ try:
343
+ await asyncio.shield(teardown)
344
+ except asyncio.CancelledError:
345
+ # The attempt was cancelled while the teardown ran. `pre_exit` is exactly the
346
+ # hook an operator installs to clean up after a cancel, so give it a bounded
347
+ # second chance before letting the cancellation through.
348
+ with contextlib.suppress(Exception, asyncio.CancelledError, TimeoutError):
349
+ await asyncio.wait_for(
350
+ asyncio.shield(teardown), timeout=self._teardown_grace_seconds
351
+ )
352
+ raise
353
+
354
+ def installed(self, name: HookName) -> bool:
355
+ """Whether this slot has a hook — asked before the one override, ``checkout``."""
356
+
357
+ return self._hooks.has(name)
358
+
359
+ async def phase(self, name: HookName) -> HookRun | None:
360
+ """Run one slot, enforcing the catalogue's fatality rule.
361
+
362
+ Fatal through ``pre_runtime`` (:data:`FATAL_HOOKS`); ``pre_directive`` refuses the
363
+ Directive outright. Later slots — ``post_runtime``, ``post_verify``,
364
+ ``post_artifact`` — are recorded and carried on, so a failing ``post_verify``
365
+ never changes the Verifier's verdict.
366
+ """
367
+
368
+ run = await self._run(name)
369
+ if run is None:
370
+ return None
371
+ if run.failed:
372
+ self._exit_code = run.exit_code
373
+ if name is HookName.PRE_DIRECTIVE:
374
+ await self._append_evidence(
375
+ DIRECTIVE_REJECTED_EVIDENCE_SOURCE,
376
+ {
377
+ "work_record_id": self._facts.work_record_id,
378
+ "directive_id": self._facts.directive_id,
379
+ "hook": name.value,
380
+ "exit_code": run.exit_code,
381
+ "reason": "the pre_directive reject gate refused this Directive",
382
+ },
383
+ )
384
+ raise DirectiveRejectedError(run)
385
+ if name in FATAL_HOOKS:
386
+ raise HookRefusedError(run)
387
+ return run
388
+
389
+ async def environment(self) -> dict[str, str]:
390
+ """Run the ``environment`` hook and return what it exported.
391
+
392
+ Buildkite sources the hook and diffs the shell environment; a polyglot hook there
393
+ has to call the Job API instead. One mechanism is enough for both: the hook writes
394
+ ``NAME=value`` lines to :data:`ENV_FILE_ENV`, so a compiled hook and a shell hook
395
+ export the same way and nothing has to be sourced into the Runner's own process.
396
+
397
+ Reserved names are dropped, not honoured: see :data:`RESERVED_DIRECTIVE_ENV`.
398
+ """
399
+
400
+ if not self._hooks.has(HookName.ENVIRONMENT):
401
+ return {}
402
+ env_file = self._attempt_dir / "environment.env"
403
+ await self.phase(HookName.ENVIRONMENT)
404
+ return _read_env_file(env_file)
405
+
406
+ async def _run(self, name: HookName) -> HookRun | None:
407
+ """Run one slot and record it. Every hook run is an Evidence Event (ticket 26 §1)."""
408
+
409
+ run = await self._hooks.run(
410
+ name,
411
+ cwd=self._workspace_path,
412
+ facts=self._facts,
413
+ sandbox=self._sandbox,
414
+ extra_env=self._slot_env(name),
415
+ )
416
+ if run is not None:
417
+ await self._append_evidence(HOOK_EVIDENCE_SOURCE, run.evidence())
418
+ return run
419
+
420
+ def _slot_env(self, name: HookName) -> dict[str, str]:
421
+ if name is HookName.ENVIRONMENT:
422
+ return {ENV_FILE_ENV: str(self._attempt_dir / "environment.env")}
423
+ if name is HookName.PRE_DIRECTIVE:
424
+ return {PROPOSED_ENV_FILE_ENV: str(self._write_proposed_env_file())}
425
+ if name is HookName.PRE_EXIT:
426
+ return {f"{_FACT_ENV_PREFIX}EXIT_CODE": str(self._exit_code)}
427
+ return {}
428
+
429
+ def _write_proposed_env_file(self) -> Path:
430
+ """The names — never the values — the Agent Runtime subprocess is about to get."""
431
+
432
+ path = self._attempt_dir / "proposed-env.names"
433
+ path.write_text("\n".join(self._proposed_env_names) + "\n", encoding="utf-8")
434
+ path.chmod(0o600)
435
+ if self._sandbox is not None and self._sandbox.uid is not None:
436
+ os.chown(path, self._sandbox.uid, self._sandbox.uid)
437
+ return path
438
+
439
+ async def _run_pre_exit(self) -> None:
440
+ await self._run(HookName.PRE_EXIT)
441
+
442
+
443
+ def _read_env_file(path: Path) -> dict[str, str]:
444
+ if not path.is_file():
445
+ return {}
446
+ exports: dict[str, str] = {}
447
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
448
+ name, separator, value = line.partition("=")
449
+ name = name.strip()
450
+ if not separator or not _ENV_NAME_RE.fullmatch(name):
451
+ continue
452
+ if name in RESERVED_DIRECTIVE_ENV:
453
+ continue
454
+ exports[name] = value
455
+ return exports