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,134 @@
1
+ """The Helm release's Recipient Key: one Secret, created by the first Runner (issue 46, 22 A4).
2
+
3
+ A Helm release is one *installation*, so its N replicas must register one key -- and Helm
4
+ cannot mint an X25519 pair in a template. The chart's init container runs
5
+ ``agentic-runner recipient-key ensure-secret`` instead: read the Secret, create it if it
6
+ is absent, and hand the pair to this pod's ``RecipientKeyStore`` as an installer-managed
7
+ key. Two replicas starting at once both try to create; the API's 409 sends the loser
8
+ back to read what the winner wrote, so exactly one key exists per release.
9
+
10
+ The Kubernetes API is spoken to directly over httpx with the pod's projected
11
+ ServiceAccount token: a client library for two calls on one resource would be a
12
+ dependency in a package that ships to clients (ADR-0013 §4). The Role the chart grants
13
+ is ``get`` on that one Secret name and ``create`` on Secrets, nothing wider.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import os
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Final
23
+
24
+ import httpx
25
+
26
+ from agentic_runner.sealed_box import RecipientKeyPair, RecipientKeyStore, generate_recipient_key
27
+
28
+ __all__ = ["KubernetesSecrets", "ensure_recipient_key", "in_cluster"]
29
+
30
+ SERVICE_ACCOUNT_DIR: Final[Path] = Path("/var/run/secrets/kubernetes.io/serviceaccount")
31
+ _KEYS: Final[tuple[str, ...]] = ("key_id", "public_key", "private_key")
32
+
33
+
34
+ class KubernetesSecrets:
35
+ """Read and create Secrets in one namespace, as the pod's ServiceAccount."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ base_url: str,
41
+ kube_ns: str,
42
+ token: str,
43
+ client: httpx.Client,
44
+ ) -> None:
45
+ self._url = f"{base_url.rstrip('/')}/api/v1/namespaces/{kube_ns}/secrets"
46
+ self._headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
47
+ self._client = client
48
+
49
+ def get(self, name: str) -> RecipientKeyPair | None:
50
+ response = self._client.get(f"{self._url}/{name}", headers=self._headers)
51
+ if response.status_code == 404:
52
+ return None
53
+ response.raise_for_status()
54
+ data = response.json().get("data") or {}
55
+ values = {key: _decode(data.get(key)) for key in _KEYS}
56
+ if not all(values.values()):
57
+ raise RuntimeError(f"Secret {name} exists but does not hold a Recipient Key")
58
+ return RecipientKeyPair(**values)
59
+
60
+ def create(self, name: str, pair: RecipientKeyPair) -> bool:
61
+ """Create the Secret; ``False`` when another replica got there first."""
62
+
63
+ body = {
64
+ "apiVersion": "v1",
65
+ "kind": "Secret",
66
+ "metadata": {"name": name, "labels": {"app.kubernetes.io/name": "agentic-runner"}},
67
+ "type": "Opaque",
68
+ "data": {
69
+ "key_id": _encode(pair.key_id),
70
+ "public_key": _encode(pair.public_key),
71
+ "private_key": _encode(pair.private_key),
72
+ },
73
+ }
74
+ response = self._client.post(self._url, headers=self._headers, json=body)
75
+ if response.status_code == 409:
76
+ return False
77
+ response.raise_for_status()
78
+ return True
79
+
80
+
81
+ def in_cluster(*, service_account_dir: Path = SERVICE_ACCOUNT_DIR) -> tuple[KubernetesSecrets, str]:
82
+ """The API as this pod sees it, and the Kubernetes namespace the pod is in."""
83
+
84
+ host = _env("KUBERNETES_SERVICE_HOST")
85
+ port = _env("KUBERNETES_SERVICE_PORT")
86
+ kube_ns = (service_account_dir / "namespace").read_text(encoding="utf-8").strip()
87
+ token = (service_account_dir / "token").read_text(encoding="utf-8").strip()
88
+ client = httpx.Client(verify=str(service_account_dir / "ca.crt"), timeout=10.0)
89
+ return (
90
+ KubernetesSecrets(
91
+ base_url=f"https://{host}:{port}",
92
+ kube_ns=kube_ns,
93
+ token=token,
94
+ client=client,
95
+ ),
96
+ kube_ns,
97
+ )
98
+
99
+
100
+ def ensure_recipient_key(
101
+ api: KubernetesSecrets,
102
+ *,
103
+ secret_name: str,
104
+ kube_ns: str,
105
+ state_dir: Path,
106
+ now: datetime | None = None,
107
+ ) -> RecipientKeyPair:
108
+ """The release's key, created once and installed into this pod's state directory."""
109
+
110
+ pair = api.get(secret_name)
111
+ if pair is None:
112
+ fresh = generate_recipient_key()
113
+ pair = fresh if api.create(secret_name, fresh) else api.get(secret_name)
114
+ if pair is None:
115
+ raise RuntimeError(f"Secret {secret_name} vanished between create and read")
116
+ RecipientKeyStore(state_dir).install(
117
+ pair, managed_by=f"secret:{kube_ns}/{secret_name}", now=now or datetime.now(UTC)
118
+ )
119
+ return pair
120
+
121
+
122
+ def _env(name: str) -> str:
123
+ value = os.environ.get(name, "").strip()
124
+ if not value:
125
+ raise RuntimeError(f"{name} is not set; not running inside a Kubernetes pod")
126
+ return value
127
+
128
+
129
+ def _encode(value: str) -> str:
130
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
131
+
132
+
133
+ def _decode(value: object) -> str:
134
+ return base64.b64decode(str(value)).decode("utf-8") if value else ""
@@ -0,0 +1,363 @@
1
+ """The Runner's half of registration and heartbeat (PRD issue 41, ADR-0013 §8).
2
+
3
+ Outbound-only: this process dials the control plane, presents its Agent Token once, keeps
4
+ what comes back in its own state directory, and then says what it is every 30 s. Nothing
5
+ listens on a port here, which is also why revocation is entirely the control plane's --
6
+ the lever it pulls is the refused heartbeat.
7
+
8
+ Two things fail closed on this side, and both fail *before* anything registers:
9
+
10
+ * **Isolation mode** (17 A2). A Runner configured ``contract_uid`` that cannot actually
11
+ separate uids refuses to start, rather than silently degrading to a shared uid after
12
+ someone edits a ``securityContext``. Auto-detection was rejected for exactly that.
13
+ * **A payload newer than this Runner's contracts** (25 §7). The control plane holds
14
+ Directives to a Runner two minors behind, but the Runner is the backstop: it refuses to
15
+ execute a payload whose contracts version it cannot claim to understand, so a floor that
16
+ was misconfigured on the platform side still cannot make it guess.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import contextlib
23
+ import os
24
+ from datetime import UTC, datetime
25
+ from email.utils import parsedate_to_datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from uuid import UUID
29
+
30
+ import httpx
31
+ from cryptography.hazmat.primitives import serialization
32
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
33
+ from pydantic import BaseModel, ConfigDict
34
+
35
+ from agentic_runner import __version__ as runner_version
36
+ from agentic_runner_contracts import __version__ as contracts_version
37
+ from agentic_runner_contracts.channel_messages import TRANSCRIPT_DELIVERIES_PATH, TranscriptDelivery
38
+ from agentic_runner_contracts.runner_registration import (
39
+ BootstrapRequest,
40
+ BootstrapResponse,
41
+ DirectiveTokenRequest,
42
+ DirectiveTokenResponse,
43
+ HeartbeatAck,
44
+ HeartbeatEnvelope,
45
+ IsolationMode,
46
+ RecipientKey,
47
+ )
48
+ from agentic_runner_contracts.runner_signature import (
49
+ RUNNER_ID_HEADER,
50
+ SIGNATURE_HEADER,
51
+ SIGNED_AT_HEADER,
52
+ canonical_request,
53
+ format_signed_at,
54
+ )
55
+ from agentic_runner_contracts.user_sources import (
56
+ IntakeIgnoredReport,
57
+ IntakeWorkRequest,
58
+ IntakeWorkResponse,
59
+ )
60
+
61
+ BOOTSTRAP_PATH = "/api/runner/v1/runners/bootstrap"
62
+ HEARTBEAT_PATH = "/api/runner/v1/runners/heartbeat"
63
+ DIRECTIVE_TOKEN_PATH = "/api/runner/v1/runners/directive-token"
64
+ INTAKE_WORK_RECORDS_PATH = "/api/runner/v1/runners/intake/work-records"
65
+ INTAKE_IGNORED_PATH = "/api/runner/v1/runners/intake/ignored"
66
+
67
+ # What the state directory holds after a successful bootstrap. One file, mode 0600: the
68
+ # private key is in it, and a Runner that loses it re-bootstraps rather than recovering.
69
+ STATE_FILENAME = "runner-identity.json"
70
+
71
+
72
+ class RunnerRegistrationError(RuntimeError):
73
+ """The Runner cannot register or heartbeat; ``reason`` is the control plane's code."""
74
+
75
+ def __init__(self, reason: str, message: str) -> None:
76
+ super().__init__(message)
77
+ self.reason = reason
78
+
79
+
80
+ class RunnerState(BaseModel):
81
+ """What this process is, once registered. Persisted; reread on every restart.
82
+
83
+ A model rather than a dataclass so the state file round-trips through one
84
+ ``model_validate_json``/``model_dump_json`` pair: a hand-written reader would spell
85
+ ``task_queue`` as a literal key, and a Public Metadata name is the builder's to spell.
86
+ """
87
+
88
+ model_config = ConfigDict(extra="forbid", frozen=True)
89
+
90
+ runner_id: UUID
91
+ identity_id: str
92
+ private_key_pem: str
93
+ temporal_namespace: str
94
+ task_queue: str
95
+ # What this Runner asserts every Directive's routing against (PRD issue 42): the host
96
+ # party the control plane registered it as, and the Tags it registered with. Both
97
+ # default so a state file written before issue 42 still loads -- such a process makes
98
+ # no routing assertion until it re-bootstraps, which is the introduce-before-require
99
+ # posture every other seam here takes.
100
+ host_party: str = ""
101
+ tags: dict[str, str] = {}
102
+ max_concurrent_directives: int = 1
103
+ # The control plane's cadence, as bootstrap told it (06 §4's 30 s by default); a
104
+ # state file written before issue 47 carries none and keeps the default.
105
+ heartbeat_interval_seconds: int = 30
106
+
107
+ def signer(self) -> Ed25519PrivateKey:
108
+ key = serialization.load_pem_private_key(
109
+ self.private_key_pem.encode("utf-8"), password=None
110
+ )
111
+ if not isinstance(key, Ed25519PrivateKey):
112
+ raise RunnerRegistrationError(
113
+ "identity_unusable", "the persisted Runner identity is not an Ed25519 key"
114
+ )
115
+ return key
116
+
117
+ def signed_headers(
118
+ self, method: str, url: str, body: bytes, *, now: datetime | None = None
119
+ ) -> dict[str, str]:
120
+ """The signed Runner envelope for one request (issue 71): every signing site's only path.
121
+
122
+ The path signed is the one on the wire -- ``url``'s raw target, query included --
123
+ so a base URL carrying a path prefix signs what the platform actually receives.
124
+ """
125
+
126
+ signed_at = format_signed_at(now or datetime.now(UTC))
127
+ message = canonical_request(
128
+ method=method,
129
+ path=httpx.URL(url).raw_path.decode("ascii"),
130
+ signed_at=signed_at,
131
+ body=body,
132
+ )
133
+ return {
134
+ RUNNER_ID_HEADER: str(self.runner_id),
135
+ SIGNATURE_HEADER: base64.b64encode(self.signer().sign(message)).decode("ascii"),
136
+ SIGNED_AT_HEADER: signed_at,
137
+ }
138
+
139
+
140
+ def require_isolation_supported(mode: IsolationMode, *, can_separate_uids: bool) -> None:
141
+ """17 A2, the fail-closed half: a ``contract_uid`` Runner that cannot setuid stops.
142
+
143
+ Declared, never detected. A Runner that quietly downgraded to ``none`` would keep
144
+ taking Directives from several Contracts while sharing one uid between them, which is
145
+ the exact failure the mode exists to prevent.
146
+ """
147
+
148
+ if mode is IsolationMode.CONTRACT_UID and not can_separate_uids:
149
+ raise RunnerRegistrationError(
150
+ "isolation_unavailable",
151
+ "isolation is configured `contract_uid` but this process lacks CAP_SETUID and "
152
+ "cannot separate uids; run it as root with SETUID, SETGID, CHOWN, FOWNER and "
153
+ "DAC_OVERRIDE (ADR-0015 §1), or configure `none` and accept a single-Contract "
154
+ "Runner",
155
+ )
156
+
157
+
158
+ # CAP_SETUID's bit in the kernel's capability bitmask (`capability.h`).
159
+ _CAP_SETUID_BIT = 7
160
+
161
+
162
+ def can_separate_uids(*, proc_status: Path = Path("/proc/self/status")) -> bool:
163
+ """Whether this process can actually change uid (17 A2's honest answer).
164
+
165
+ Read from the effective capability set on Linux, because `geteuid() == 0` is not it:
166
+ a container that runs as root with ALL dropped -- the `restricted` Pod Security
167
+ level's shape -- is root and still cannot `setuid`. Elsewhere root is the only
168
+ signal there is.
169
+ """
170
+
171
+ try:
172
+ status = proc_status.read_text(encoding="utf-8")
173
+ except OSError:
174
+ return os.geteuid() == 0
175
+ for line in status.splitlines():
176
+ if line.startswith("CapEff:"):
177
+ effective = int(line.partition(":")[2].strip(), 16)
178
+ return bool(effective >> _CAP_SETUID_BIT & 1)
179
+ return os.geteuid() == 0
180
+
181
+
182
+ def require_payload_supported(
183
+ payload_contracts_version: str, *, own: str = contracts_version
184
+ ) -> None:
185
+ """The Runner-side backstop (25 §7): refuse a payload newer than our own contracts.
186
+
187
+ Older is fine -- the platform may address an older wire deliberately. Newer means the
188
+ payload can carry fields this build has never seen, and executing it would be a guess.
189
+ """
190
+
191
+ if _version(payload_contracts_version) > _version(own):
192
+ raise RunnerRegistrationError(
193
+ "payload_too_new",
194
+ f"payload declares contracts {payload_contracts_version}, newer than this "
195
+ f"Runner's {own}; refusing to execute it",
196
+ )
197
+
198
+
199
+ def _version(value: str) -> tuple[int, ...]:
200
+ try:
201
+ return tuple(int(part) for part in value.strip().split("."))
202
+ except ValueError as error:
203
+ raise RunnerRegistrationError(
204
+ "contracts_version_unreadable", f"cannot read a contracts version from {value!r}"
205
+ ) from error
206
+
207
+
208
+ def load_state(state_dir: Path) -> RunnerState | None:
209
+ """The persisted identity, or ``None`` on a first boot."""
210
+
211
+ path = state_dir / STATE_FILENAME
212
+ if not path.exists():
213
+ return None
214
+ return RunnerState.model_validate_json(path.read_text(encoding="utf-8"))
215
+
216
+
217
+ def save_state(state_dir: Path, state: RunnerState) -> None:
218
+ state_dir.mkdir(parents=True, exist_ok=True)
219
+ path = state_dir / STATE_FILENAME
220
+ path.write_text(state.model_dump_json(), encoding="utf-8")
221
+ # The private key is in this file; a shared PVC or a workstation home is not a place
222
+ # to leave it group-readable.
223
+ path.chmod(0o600)
224
+
225
+
226
+ class RunnerRegistrationClient:
227
+ """The two calls this Runner ever makes to register itself."""
228
+
229
+ def __init__(self, *, base_url: str, client: httpx.AsyncClient | None = None) -> None:
230
+ self._base_url = base_url.rstrip("/")
231
+ self._client = client
232
+ # The control plane's `Date` on the last answer: the reference clock the
233
+ # heartbeat's `clock_skew_seconds` is measured against (23 item 5).
234
+ self.server_date: datetime | None = None
235
+
236
+ async def bootstrap(
237
+ self,
238
+ *,
239
+ agent_token: str,
240
+ tags: dict[str, str],
241
+ isolation_mode: IsolationMode,
242
+ recipient_key: RecipientKey,
243
+ max_concurrent_directives: int = 1,
244
+ can_separate_uids: bool,
245
+ ) -> tuple[RunnerState, BootstrapResponse]:
246
+ """Exchange the Agent Token for this process's durable identity."""
247
+
248
+ require_isolation_supported(isolation_mode, can_separate_uids=can_separate_uids)
249
+ request = BootstrapRequest(
250
+ agent_token=agent_token,
251
+ tags=tags,
252
+ isolation_mode=isolation_mode,
253
+ contracts_version=contracts_version,
254
+ runner_version=runner_version,
255
+ recipient_key=recipient_key,
256
+ max_concurrent_directives=max_concurrent_directives,
257
+ )
258
+ payload = await self._post(BOOTSTRAP_PATH, content=request.model_dump_json(), headers={})
259
+ response = BootstrapResponse.model_validate(payload)
260
+ state = RunnerState(
261
+ **response.identity.model_dump(),
262
+ temporal_namespace=response.temporal_namespace,
263
+ task_queue=response.task_queue,
264
+ host_party=response.host_party,
265
+ tags=dict(tags),
266
+ max_concurrent_directives=max_concurrent_directives,
267
+ heartbeat_interval_seconds=response.heartbeat_interval_seconds,
268
+ )
269
+ return state, response
270
+
271
+ async def heartbeat(self, state: RunnerState, envelope: HeartbeatEnvelope) -> HeartbeatAck:
272
+ """Send one envelope, signed with the identity, and take back a fresh token."""
273
+
274
+ return HeartbeatAck.model_validate(await self._signed(state, HEARTBEAT_PATH, envelope))
275
+
276
+ async def request_directive_token(
277
+ self, state: RunnerState, directive_id: str
278
+ ) -> DirectiveTokenResponse:
279
+ """Pull one Directive's control-plane token by id (PRD issue 63, 25 §11).
280
+
281
+ Signed like the heartbeat; the refusal reasons (revoked, held, routed elsewhere)
282
+ come back as the same :class:`RunnerRegistrationError`.
283
+ """
284
+
285
+ payload = await self._signed(
286
+ state, DIRECTIVE_TOKEN_PATH, DirectiveTokenRequest(directive_id=directive_id)
287
+ )
288
+ return DirectiveTokenResponse.model_validate(payload)
289
+
290
+ async def create_intake_work_record(
291
+ self, state: RunnerState, request: IntakeWorkRequest
292
+ ) -> IntakeWorkResponse:
293
+ """The create-Work-Record seam (PRD issue 50), under the Runner identity."""
294
+
295
+ payload = await self._signed(state, INTAKE_WORK_RECORDS_PATH, request)
296
+ return IntakeWorkResponse.model_validate(payload)
297
+
298
+ async def report_intake_ignored(self, state: RunnerState, report: IntakeIgnoredReport) -> None:
299
+ await self._signed(state, INTAKE_IGNORED_PATH, report)
300
+
301
+ async def deliver_transcript(self, state: RunnerState, delivery: TranscriptDelivery) -> None:
302
+ """Answer one transcript pull-through (PRD issue 52), under the Runner identity."""
303
+
304
+ await self._signed(state, TRANSCRIPT_DELIVERIES_PATH, delivery)
305
+
306
+ async def _signed(self, state: RunnerState, path: str, model: BaseModel) -> dict[str, Any]:
307
+ # Signed over these exact bytes, which is why the body is serialised once here and
308
+ # never re-serialised on the way out.
309
+ body = model.model_dump_json().encode("utf-8")
310
+ return await self._post(
311
+ path,
312
+ content=body,
313
+ headers=state.signed_headers("POST", f"{self._base_url}{path}", body),
314
+ )
315
+
316
+ async def _post(
317
+ self, path: str, *, content: str | bytes, headers: dict[str, str]
318
+ ) -> dict[str, Any]:
319
+ client = self._client or httpx.AsyncClient()
320
+ owned = self._client is None
321
+ try:
322
+ response = await client.post(
323
+ f"{self._base_url}{path}",
324
+ content=content,
325
+ headers={"content-type": "application/json", **headers},
326
+ )
327
+ finally:
328
+ if owned:
329
+ await client.aclose()
330
+ with contextlib.suppress(TypeError, ValueError):
331
+ self.server_date = parsedate_to_datetime(response.headers.get("date", ""))
332
+ if response.status_code >= 400:
333
+ raise RunnerRegistrationError(*_refusal(response))
334
+ if not response.content:
335
+ return {}
336
+ result: dict[str, Any] = response.json()
337
+ return result
338
+
339
+
340
+ def _refusal(response: httpx.Response) -> tuple[str, str]:
341
+ """Pull the control plane's ``{"reason", "detail"}`` back out of an error body."""
342
+
343
+ try:
344
+ detail = response.json().get("detail")
345
+ except ValueError:
346
+ detail = None
347
+ if isinstance(detail, dict) and "reason" in detail:
348
+ return str(detail["reason"]), str(detail.get("detail", detail["reason"]))
349
+ return "refused", f"{response.status_code}: {response.text[:200]}"
350
+
351
+
352
+ class SignedIntakeStream:
353
+ """:class:`agentic_runner.user_sources.IntakeStream` over the signed outbound stream."""
354
+
355
+ def __init__(self, client: RunnerRegistrationClient, state: RunnerState) -> None:
356
+ self._client = client
357
+ self._state = state
358
+
359
+ async def create_work_record(self, request: IntakeWorkRequest) -> IntakeWorkResponse:
360
+ return await self._client.create_intake_work_record(self._state, request)
361
+
362
+ async def report_ignored(self, report: IntakeIgnoredReport) -> None:
363
+ await self._client.report_intake_ignored(self._state, report)
File without changes