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
|
@@ -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
|