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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
agentic_runner/hooks.py
ADDED
|
@@ -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
|