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,342 @@
1
+ """The per-attempt callback socket (ADR-0013 §10, map ticket 26 §4, 25 §10).
2
+
3
+ Buildkite's Job API, narrowed to what ADR-0011 §9 can defend. For each Directive attempt
4
+ the Runner mints 32 random bytes as a bearer and opens a Unix socket **owned by the
5
+ Contract's uid** (map ticket 17 A11); the Agent Runtime subprocess receives the socket
6
+ path and the bearer in its environment and nothing else. ``agentic-runner annotate |
7
+ artifact upload | verb`` speak to it.
8
+
9
+ What makes that not a credential: the token's only audience is this Runner's own socket,
10
+ it is useless off the box, it dies with the attempt, and it is a *correlator* rather than
11
+ an authority — a ``verb`` call is one more entry point into the same Grant evaluation the
12
+ activity seams run, attributed to the Agent of that attempt, never a bypass (ADR-0011
13
+ §10). The same bearer is what the relocated LLM proxy accepts (PRD issue 43), so one
14
+ token per attempt serves both.
15
+
16
+ **Not offered**, deliberately: a `pipeline upload` analogue (an Agent rewriting its own
17
+ loop is the thing the platform owns), `meta-data`, and any route that returns a
18
+ credential — :data:`ROUTES` is the closed set and
19
+ ``tests/unit/test_runner_callback_socket.py`` asserts no field in it is credential-shaped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import os
26
+ import secrets
27
+ from collections.abc import Awaitable, Callable, Mapping
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from types import TracebackType
31
+ from typing import Any, Final, Literal, Self
32
+
33
+ import httpx
34
+ from pydantic import BaseModel, Field
35
+
36
+ from agentic_runner.tiny_http import (
37
+ MAX_HEADER_BYTES,
38
+ HttpRequest,
39
+ bearer_matches,
40
+ read_request,
41
+ write_json,
42
+ )
43
+ from agentic_runner_contracts.channel_messages import (
44
+ MESSAGE_BODY_MAX_BYTES,
45
+ MessageEnvelope,
46
+ MessageKind,
47
+ MessageReference,
48
+ )
49
+ from agentic_runner_contracts.questions import QUESTION_TEXT_MAX
50
+
51
+ __all__ = [
52
+ "CALLBACK_SOCKET_ENV",
53
+ "CALLBACK_TOKEN_ENV",
54
+ "ROUTES",
55
+ "AnnotateRequest",
56
+ "AnnotateResponse",
57
+ "AskRequest",
58
+ "AskResponse",
59
+ "ArtifactRequest",
60
+ "ArtifactResponse",
61
+ "AttemptCallbackServer",
62
+ "CallbackError",
63
+ "CallbackHandlers",
64
+ "MessageListRequest",
65
+ "MessageListResponse",
66
+ "MessageSendRequest",
67
+ "MessageSendResponse",
68
+ "Route",
69
+ "VerbRequest",
70
+ "VerbResponse",
71
+ "call",
72
+ ]
73
+
74
+ CALLBACK_SOCKET_ENV: Final[str] = "AGENTIC_RUNNER_CALLBACK_SOCKET"
75
+ CALLBACK_TOKEN_ENV: Final[str] = "AGENTIC_RUNNER_CALLBACK_TOKEN"
76
+
77
+ _TOKEN_BYTES: Final[int] = 32
78
+ # `sockaddr_un.sun_path` is 108 bytes on Linux and 104 on macOS, and the failure mode of
79
+ # overrunning it is a truncated path that binds somewhere else entirely. The Workspace
80
+ # root plus a Contract id plus a Work Record id is already past it, which is why the
81
+ # socket lives in its own short directory rather than beside the checkout.
82
+ MAX_SOCKET_PATH_BYTES: Final[int] = 100
83
+
84
+
85
+ class AnnotateRequest(BaseModel):
86
+ context: str = Field(max_length=100)
87
+ body: str = Field(max_length=65_536)
88
+ style: str = Field(default="info", pattern=r"^(info|success|warning|error)$")
89
+
90
+
91
+ class AnnotateResponse(BaseModel):
92
+ accepted: bool
93
+ context: str
94
+
95
+
96
+ class ArtifactRequest(BaseModel):
97
+ """One file already in the Workspace, offered for upload by path.
98
+
99
+ Never file *contents*: the Runner reads the path itself as the Contract's data, which
100
+ keeps a multi-gigabyte upload off the socket and off the Runner's heap.
101
+ """
102
+
103
+ path: str = Field(max_length=4096)
104
+ label: str = Field(default="", max_length=200)
105
+
106
+
107
+ class ArtifactResponse(BaseModel):
108
+ accepted: bool
109
+ path: str
110
+ size_bytes: int
111
+ sha256: str
112
+ reason: str = ""
113
+
114
+
115
+ class VerbRequest(BaseModel):
116
+ verb: str = Field(max_length=100)
117
+ resource: str = Field(max_length=400)
118
+
119
+
120
+ class VerbResponse(BaseModel):
121
+ """The seam's own verdict. No token, no credential — only what was decided and why."""
122
+
123
+ verb: str
124
+ resource: str
125
+ decision: str
126
+ allowed: bool
127
+ reason: str
128
+
129
+
130
+ class MessageSendRequest(BaseModel):
131
+ """``agentic-runner message send`` (PRD issue 52): the envelope minus what the Runner
132
+ fills in itself -- sender, Work Record, id and time are the attempt's, never the
133
+ caller's to claim."""
134
+
135
+ channel_id: str = Field(max_length=64)
136
+ kind: MessageKind = MessageKind.NOTE
137
+ body: str = Field(max_length=MESSAGE_BODY_MAX_BYTES)
138
+ references: list[MessageReference] = Field(default_factory=list, max_length=32)
139
+ # PRD issue 53: who this is for. A `result` with neither is addressed to the sender
140
+ # of the `message` it references, which the Runner resolves from its own store.
141
+ recipient_agent_id: str | None = Field(default=None, max_length=64)
142
+ recipient_role: str | None = Field(default=None, max_length=32)
143
+ # PRD issue 54: a `verdict` names the head commit it reviewed and carries one bit.
144
+ verdict: Literal["block", "clear"] | None = None
145
+ head_sha: str | None = Field(default=None, max_length=64)
146
+
147
+
148
+ class MessageSendResponse(BaseModel):
149
+ """Whether the `channel.write` seam let it through, and the id it got if so."""
150
+
151
+ accepted: bool
152
+ decision: str
153
+ reason: str
154
+ message_id: str = ""
155
+
156
+
157
+ class MessageListRequest(BaseModel):
158
+ channel_id: str = Field(max_length=64)
159
+
160
+
161
+ class MessageListResponse(BaseModel):
162
+ """The Channel's Messages under `channel.read`; empty with the decision otherwise."""
163
+
164
+ allowed: bool
165
+ decision: str
166
+ reason: str
167
+ messages: list[MessageEnvelope] = Field(default_factory=list)
168
+
169
+
170
+ class AskRequest(BaseModel):
171
+ """``agentic-runner ask`` (PRD issue 60): the Question's text and nothing else --
172
+ the asking Agent, the Work Record and the addressee are the attempt's to know."""
173
+
174
+ text: str = Field(min_length=1, max_length=QUESTION_TEXT_MAX)
175
+
176
+
177
+ class AskResponse(BaseModel):
178
+ """`work.ask`'s verdict. ``accepted`` means the Question stands: the Work Record holds
179
+ on it once this Directive ends, and the next Directive carries the answer (or the
180
+ fact that none came). A refusal leaves the Directive to carry on without it."""
181
+
182
+ accepted: bool
183
+ decision: str
184
+ reason: str
185
+ question_id: str = ""
186
+ addressed_to: str = ""
187
+
188
+
189
+ @dataclass(frozen=True, slots=True)
190
+ class Route:
191
+ request: type[BaseModel]
192
+ response: type[BaseModel]
193
+ handler: str
194
+
195
+
196
+ ROUTES: Final[dict[tuple[str, str], Route]] = {
197
+ ("POST", "/v0/annotate"): Route(AnnotateRequest, AnnotateResponse, "annotate"),
198
+ ("POST", "/v0/artifact"): Route(ArtifactRequest, ArtifactResponse, "artifact"),
199
+ ("POST", "/v0/verb"): Route(VerbRequest, VerbResponse, "verb"),
200
+ ("POST", "/v0/message/send"): Route(MessageSendRequest, MessageSendResponse, "message_send"),
201
+ ("POST", "/v0/message/list"): Route(MessageListRequest, MessageListResponse, "message_list"),
202
+ ("POST", "/v0/ask"): Route(AskRequest, AskResponse, "ask"),
203
+ }
204
+
205
+
206
+ @dataclass(frozen=True, slots=True)
207
+ class CallbackHandlers:
208
+ """What the Runner does when a Directive calls back. Injected by the activity."""
209
+
210
+ annotate: Callable[[AnnotateRequest], Awaitable[AnnotateResponse]]
211
+ artifact: Callable[[ArtifactRequest], Awaitable[ArtifactResponse]]
212
+ verb: Callable[[VerbRequest], Awaitable[VerbResponse]]
213
+ message_send: Callable[[MessageSendRequest], Awaitable[MessageSendResponse]]
214
+ message_list: Callable[[MessageListRequest], Awaitable[MessageListResponse]]
215
+ ask: Callable[[AskRequest], Awaitable[AskResponse]]
216
+
217
+
218
+ class CallbackError(RuntimeError):
219
+ """A callback the Runner refused: the status and the body it answered with."""
220
+
221
+ def __init__(self, status_code: int, detail: str) -> None:
222
+ super().__init__(f"callback refused ({status_code}): {detail}")
223
+ self.status_code = status_code
224
+ self.detail = detail
225
+
226
+
227
+ class AttemptCallbackServer:
228
+ """One attempt's socket. Opened on enter, unlinked on exit; the token dies with it."""
229
+
230
+ def __init__(
231
+ self,
232
+ *,
233
+ socket_path: Path,
234
+ handlers: CallbackHandlers,
235
+ uid: int | None = None,
236
+ token: str | None = None,
237
+ ) -> None:
238
+ if len(os.fsencode(socket_path)) > MAX_SOCKET_PATH_BYTES:
239
+ raise ValueError(
240
+ f"callback socket path is longer than {MAX_SOCKET_PATH_BYTES} bytes: {socket_path}"
241
+ )
242
+ self._socket_path = socket_path
243
+ self._handlers = handlers
244
+ self._uid = uid
245
+ self._token = token or secrets.token_urlsafe(_TOKEN_BYTES)
246
+ self._server: asyncio.AbstractServer | None = None
247
+
248
+ @property
249
+ def token(self) -> str:
250
+ return self._token
251
+
252
+ @property
253
+ def socket_path(self) -> Path:
254
+ return self._socket_path
255
+
256
+ def env(self) -> dict[str, str]:
257
+ """The two names the Agent Runtime subprocess gains, and nothing else."""
258
+
259
+ return {CALLBACK_SOCKET_ENV: str(self._socket_path), CALLBACK_TOKEN_ENV: self._token}
260
+
261
+ async def __aenter__(self) -> Self:
262
+ self._socket_path.unlink(missing_ok=True)
263
+ # `limit` is what actually bounds the request head: `readuntil` raises
264
+ # LimitOverrunError at the reader's own limit, so leaving it at the 64 KiB default
265
+ # would mean the 431 below never fires and an oversized head answered 500.
266
+ self._server = await asyncio.start_unix_server(
267
+ self._serve, path=str(self._socket_path), limit=MAX_HEADER_BYTES
268
+ )
269
+ # Order matters: 0600 before the chown, so there is no window in which the socket
270
+ # is both world-writable and owned by the Contract.
271
+ os.chmod(self._socket_path, 0o600)
272
+ if self._uid is not None:
273
+ os.chown(self._socket_path, self._uid, self._uid)
274
+ return self
275
+
276
+ async def __aexit__(
277
+ self,
278
+ exc_type: type[BaseException] | None,
279
+ exc: BaseException | None,
280
+ traceback: TracebackType | None,
281
+ ) -> None:
282
+ server, self._server = self._server, None
283
+ if server is not None:
284
+ server.close()
285
+ await server.wait_closed()
286
+ self._socket_path.unlink(missing_ok=True)
287
+
288
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
289
+ try:
290
+ status, payload = await self._handle(reader)
291
+ except Exception as error: # noqa: BLE001 - a handler fault answers 500, never kills the socket
292
+ status, payload = 500, {"detail": error.__class__.__name__}
293
+ await write_json(writer, status, payload)
294
+ writer.close()
295
+
296
+ async def _handle(self, reader: asyncio.StreamReader) -> tuple[int, dict[str, Any]]:
297
+ request = await read_request(reader)
298
+ if not isinstance(request, HttpRequest):
299
+ return request
300
+ if not bearer_matches(request.authorization, self._token):
301
+ return 401, {"detail": "a valid attempt bearer is required"}
302
+ route = ROUTES.get((request.method, request.target))
303
+ if route is None:
304
+ return 404, {"detail": "no such callback route"}
305
+ try:
306
+ parsed = route.request.model_validate_json(request.body or b"{}")
307
+ except ValueError as error:
308
+ return 422, {"detail": str(error)[:500]}
309
+ handler: Callable[[BaseModel], Awaitable[BaseModel]] = getattr(
310
+ self._handlers, route.handler
311
+ )
312
+ return 200, (await handler(parsed)).model_dump(mode="json")
313
+
314
+
315
+ def call(
316
+ *,
317
+ socket_path: str | Path,
318
+ token: str,
319
+ path: str,
320
+ payload: Mapping[str, Any],
321
+ timeout_seconds: float = 30.0,
322
+ ) -> dict[str, Any]:
323
+ """One callback, over the attempt's own socket. Used by the ``agentic-runner`` CLI."""
324
+
325
+ transport = httpx.HTTPTransport(uds=str(socket_path))
326
+ with httpx.Client(transport=transport, timeout=timeout_seconds) as client:
327
+ response = client.post(
328
+ f"http://localhost{path}",
329
+ json=dict(payload),
330
+ headers={"Authorization": f"Bearer {token}"},
331
+ )
332
+ if response.status_code != 200:
333
+ raise CallbackError(response.status_code, _detail(response))
334
+ decoded: dict[str, Any] = response.json()
335
+ return decoded
336
+
337
+
338
+ def _detail(response: httpx.Response) -> str:
339
+ try:
340
+ return str(response.json().get("detail", response.text))
341
+ except ValueError:
342
+ return response.text
@@ -0,0 +1,66 @@
1
+ """Keep a *stopped* child from wedging the Runner's event loop (QTS-1148).
2
+
3
+ Python 3.14's asyncio watches children without pidfd (macOS) with
4
+ ``_ThreadedChildWatcher``: a thread blocks in ``waitid(WEXITED | WNOWAIT)``, then hands
5
+ the reap to the loop thread, which calls a blocking ``waitpid(pid, 0)``. macOS's
6
+ ``waitid`` also returns for a child that is only stopped (``CLD_STOPPED``), so a
7
+ ``SIGSTOP``/``SIGTSTP`` on any Runner spawn -- the Agent Runtime CLI, an MCP server, a
8
+ sign-in -- freezes the loop until that child exits: no heartbeats, no polling, no
9
+ cancellation, and the Directive timeout cannot fire either. CPython has not fixed this
10
+ (main still waits on ``WEXITED | WNOWAIT`` alone, as of 2026-10).
11
+
12
+ The fix sits in front of the watcher thread's wait rather than replacing the watcher: it
13
+ consumes stop and continue reports until the report is a real exit, then lets the stock
14
+ code run, which now returns at once. It reaches into asyncio's private watcher, so it
15
+ installs only when it finds exactly the class it was written against and otherwise
16
+ leaves the loop alone; ``tests/unit/test_runner_child_watcher.py`` fails on macOS if
17
+ the wedge comes back.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import os
24
+ from asyncio import unix_events
25
+ from typing import Any, Final
26
+
27
+ _EXIT_CODES: Final = frozenset({os.CLD_EXITED, os.CLD_KILLED, os.CLD_DUMPED})
28
+ # typeshed leaves ``os.waitid`` out on darwin although CPython has it there, and mypy
29
+ # checks against the host platform; a getattr keeps one spelling clean on both.
30
+ _waitid: Final[Any] = getattr(os, "waitid", None)
31
+
32
+
33
+ def install_stop_tolerant_child_watcher(loop: asyncio.AbstractEventLoop) -> bool:
34
+ """Make ``loop``'s threaded child watcher wait past stops. Returns whether it did."""
35
+
36
+ threaded = getattr(unix_events, "_ThreadedChildWatcher", None)
37
+ watcher: Any = getattr(loop, "_watcher", None)
38
+ if threaded is None or type(watcher) is not threaded or _waitid is None:
39
+ return False
40
+
41
+ stock_do_waitpid = watcher._do_waitpid
42
+
43
+ def do_waitpid(
44
+ loop: asyncio.AbstractEventLoop, expected_pid: int, callback: Any, args: Any
45
+ ) -> None:
46
+ _wait_until_exited(expected_pid)
47
+ stock_do_waitpid(loop, expected_pid, callback, args)
48
+
49
+ watcher._do_waitpid = do_waitpid
50
+ return True
51
+
52
+
53
+ def _wait_until_exited(pid: int) -> None:
54
+ while True:
55
+ try:
56
+ report = _waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT)
57
+ except ChildProcessError:
58
+ return
59
+ if report is None or report.si_code in _EXIT_CODES:
60
+ return
61
+ # Without consuming it, the same stop report comes straight back under WNOWAIT.
62
+ # WSTOPPED | WCONTINUED never matches an exit, so this cannot reap the child.
63
+ try:
64
+ _waitid(os.P_PID, pid, os.WSTOPPED | os.WCONTINUED | os.WNOHANG)
65
+ except ChildProcessError:
66
+ return