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,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Protocol, Self, cast
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from agentic_runner.registration import RunnerState
|
|
13
|
+
|
|
14
|
+
# The Directive token for the activity execution under way (PRD issue 63, 25 §11).
|
|
15
|
+
# Task-local by construction: Temporal runs each activity execution in its own asyncio
|
|
16
|
+
# task, so a value set inside one is discarded with it -- never an attribute on the
|
|
17
|
+
# client or the activities object, never a cache keyed by Work Record (ADR-0013 §3),
|
|
18
|
+
# and never in an activity payload. The Runner package reads no ``INTERNAL_SERVICE_TOKEN``
|
|
19
|
+
# and sends no ``X-Internal-Service-Token``: that shared secret is the platform worker's
|
|
20
|
+
# (the platform's own ``workers.fastapi_client``), and a client-hosted Runner never holds it.
|
|
21
|
+
directive_token: ContextVar[str | None] = ContextVar("directive_token", default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DirectiveTokenSource(Protocol):
|
|
25
|
+
"""One pull of a Directive's control-plane token over the Runner's signed stream."""
|
|
26
|
+
|
|
27
|
+
async def directive_token(self, directive_id: str) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_INVALID_WORK_RECORD_TRANSITION_CODE = "invalid_work_record_transition"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WorkerFastApiClientError(RuntimeError):
|
|
34
|
+
"""Raised when the worker cannot complete an internal FastAPI request."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
*,
|
|
40
|
+
method: str | None = None,
|
|
41
|
+
path: str | None = None,
|
|
42
|
+
status_code: int | None = None,
|
|
43
|
+
detail: object | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.method = method
|
|
47
|
+
self.path = path
|
|
48
|
+
self.status_code = status_code
|
|
49
|
+
self.detail = detail
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WorkerFastApiClientRejectionError(WorkerFastApiClientError):
|
|
53
|
+
"""Deterministic backend rejection: a 4xx (other than 408/429) that a retry
|
|
54
|
+
replays verbatim. RalphWorkflow lists this type in its activity retry policy's
|
|
55
|
+
non_retryable_error_types so a poisoned request fails the activity immediately
|
|
56
|
+
instead of re-running its full Directive on every retry (work record f6223d2a:
|
|
57
|
+
38 overnight execute_fix_directive attempts against a permanent 422)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# 408/429 are the transient members of the 4xx family (server-side request timeout
|
|
61
|
+
# and rate limiting); every other 4xx is deterministic for an identical request.
|
|
62
|
+
_TRANSIENT_4XX_STATUS_CODES = frozenset({408, 429})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _error_class_for_status(status_code: int) -> type[WorkerFastApiClientError]:
|
|
66
|
+
if 400 <= status_code < 500 and status_code not in _TRANSIENT_4XX_STATUS_CODES:
|
|
67
|
+
return WorkerFastApiClientRejectionError
|
|
68
|
+
return WorkerFastApiClientError
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunnerFastApiClient:
|
|
72
|
+
"""What a Runner activity reads from, and reports to, the control plane at run time.
|
|
73
|
+
|
|
74
|
+
The Runner's half of the split ADR-0013 §2 asks for: it never calls the control plane
|
|
75
|
+
for *bookkeeping* — its activities do the thing and return facts, and the workflow
|
|
76
|
+
records them through platform activities (``PlatformRalphActivities``). What is left
|
|
77
|
+
here is what an activity genuinely cannot do without: the Work Record's runtime
|
|
78
|
+
context, the Directive's metered usage, Questions and Owner Confirmations, and the
|
|
79
|
+
Evidence its verb seams write. Every path is on the public Runner surface; the
|
|
80
|
+
authoritative list of the routes a Runner calls is this class plus
|
|
81
|
+
``registration.py``'s constants (tests/integration/test_runner_api_surface.py).
|
|
82
|
+
|
|
83
|
+
Every call carries the credential for *that* call (PRD issue 63). Anything about a
|
|
84
|
+
Work Record -- the runtime context, the Evidence a verb seam writes
|
|
85
|
+
(``append_evidence``, ``transition_work_record``, ``record_verifier_result``),
|
|
86
|
+
Questions, Owner Confirmations, usage -- bears the Directive token the activity
|
|
87
|
+
pulled for its execution (``directive_token``). Anything about the Runner itself --
|
|
88
|
+
the expired-workspace listing, the Evidence a routing refusal or a refused token pull
|
|
89
|
+
writes before a token exists -- is signed with the Runner identity exactly as the
|
|
90
|
+
heartbeat is (``state``). Nothing new may be added beside the three Evidence writes.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# The public Runner surface (issue 85, ADR-0016): every route here is served by
|
|
94
|
+
# api.agentic.<zone> and nothing else is. The platform-hosted subclass overrides it.
|
|
95
|
+
_prefix = "/api/runner/v1"
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
base_url: str,
|
|
100
|
+
timeout_seconds: float = 30.0,
|
|
101
|
+
http_client: httpx.AsyncClient | None = None,
|
|
102
|
+
state: RunnerState | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self._base_url = base_url.rstrip("/")
|
|
105
|
+
self._owns_http_client = http_client is None
|
|
106
|
+
self._http_client = http_client or httpx.AsyncClient(timeout=timeout_seconds)
|
|
107
|
+
self._state = state
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> Self:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(
|
|
113
|
+
self,
|
|
114
|
+
exc_type: type[BaseException] | None,
|
|
115
|
+
exc_value: BaseException | None,
|
|
116
|
+
traceback: TracebackType | None,
|
|
117
|
+
) -> None:
|
|
118
|
+
await self.aclose()
|
|
119
|
+
|
|
120
|
+
async def aclose(self) -> None:
|
|
121
|
+
if self._owns_http_client:
|
|
122
|
+
await self._http_client.aclose()
|
|
123
|
+
|
|
124
|
+
async def get_runtime_context(
|
|
125
|
+
self, work_record_id: str, agent_id: str | None = None
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
path = f"{self._prefix}/work-records/{_path_segment(work_record_id)}/runtime-context"
|
|
128
|
+
if agent_id:
|
|
129
|
+
path = f"{path}?agent_id={_path_segment(agent_id)}"
|
|
130
|
+
return await self._get(path)
|
|
131
|
+
|
|
132
|
+
async def raise_owner_confirmation(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
133
|
+
"""Ask the Agent's owner before a ``confirm`` verb acts (ADR-0011 §14-16).
|
|
134
|
+
|
|
135
|
+
The control plane consults the Scoped Allowance first, so the answer is either
|
|
136
|
+
"granted, proceed" or the raised request plus the window the workflow waits out.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
return await self._post(f"{self._prefix}/owner-confirmations", payload)
|
|
140
|
+
|
|
141
|
+
async def raise_question(
|
|
142
|
+
self, work_record_id: str, payload: Mapping[str, Any]
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""The `work.ask` seam (PRD issue 60): the control plane evaluates it on the Agent
|
|
145
|
+
link, records the Question it allows and raises the owner's ask on `confirm`."""
|
|
146
|
+
|
|
147
|
+
return await self._post(
|
|
148
|
+
f"{self._prefix}/work-records/{_path_segment(work_record_id)}/questions", payload
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def get_question(self, question_id: str) -> dict[str, Any]:
|
|
152
|
+
"""The Question and its answer, for the Directive the hold wakes (PRD issue 60)."""
|
|
153
|
+
|
|
154
|
+
return await self._get(f"{self._prefix}/questions/{_path_segment(question_id)}")
|
|
155
|
+
|
|
156
|
+
async def get_directive_usage(self, work_record_id: str, directive_id: str) -> dict[str, Any]:
|
|
157
|
+
"""Read one Directive's metered token aggregate (PRD issue 13).
|
|
158
|
+
|
|
159
|
+
What the activity puts in the Directive's output, so the deterministic loop
|
|
160
|
+
decrements the token Budget from an activity result and never from the ledger.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
return await self._get(
|
|
164
|
+
f"{self._prefix}/usage/work-records/{_path_segment(work_record_id)}"
|
|
165
|
+
f"/directives/{_path_segment(directive_id)}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def report_harness_usage(self, payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
169
|
+
"""One Usage Record per model, source ``"harness"`` (PRD issue 31, 17 A9): a
|
|
170
|
+
device-login or setup-token Directive bypasses the proxy, so this is the worker's
|
|
171
|
+
only way to meter it. ``payload`` is ``HarnessUsageReport``'s shape.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
result = await self._post(f"{self._prefix}/usage/harness", payload)
|
|
175
|
+
records = result.get("records")
|
|
176
|
+
return records if isinstance(records, list) else []
|
|
177
|
+
|
|
178
|
+
async def append_evidence(
|
|
179
|
+
self,
|
|
180
|
+
work_record_id: str,
|
|
181
|
+
*,
|
|
182
|
+
source: str,
|
|
183
|
+
payload: Mapping[str, Any],
|
|
184
|
+
actor: str = "temporal-worker",
|
|
185
|
+
idempotency_key: str | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
request_payload: dict[str, Any] = {
|
|
188
|
+
"actor": actor,
|
|
189
|
+
"source": source,
|
|
190
|
+
"payload": dict(payload),
|
|
191
|
+
}
|
|
192
|
+
if idempotency_key is not None:
|
|
193
|
+
request_payload["idempotency_key"] = idempotency_key
|
|
194
|
+
return await self._post(
|
|
195
|
+
f"{self._prefix}/work-records/{_path_segment(work_record_id)}/evidence",
|
|
196
|
+
request_payload,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def transition_work_record(
|
|
200
|
+
self,
|
|
201
|
+
work_record_id: str,
|
|
202
|
+
payload: Mapping[str, Any],
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
return await self._post(
|
|
205
|
+
f"{self._prefix}/work-records/{_path_segment(work_record_id)}/transition",
|
|
206
|
+
payload,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def record_verifier_result(
|
|
210
|
+
self,
|
|
211
|
+
work_record_id: str,
|
|
212
|
+
payload: Mapping[str, Any],
|
|
213
|
+
) -> dict[str, Any]:
|
|
214
|
+
return await self._post(
|
|
215
|
+
f"{self._prefix}/work-records/{_path_segment(work_record_id)}/verifier-result",
|
|
216
|
+
payload,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def list_expired_workspaces(self, work_record_ids: list[str]) -> list[str]:
|
|
220
|
+
"""Which of these Work Records' Workspaces are past the retention period (issue 30).
|
|
221
|
+
|
|
222
|
+
The Runner holds directories; whether a Work Record is terminal and how long ago
|
|
223
|
+
it ended is a control-plane fact. The ids go up, the expired subset comes back —
|
|
224
|
+
the delete itself stays on the Runner, where the files are.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
payload = await self._post(
|
|
228
|
+
f"{self._prefix}/workspaces/expired", {"work_record_ids": work_record_ids}
|
|
229
|
+
)
|
|
230
|
+
expired = payload.get("expired")
|
|
231
|
+
return [str(entry) for entry in expired] if isinstance(expired, list) else []
|
|
232
|
+
|
|
233
|
+
def _credential_headers(self, method: str, url: str, body: bytes) -> dict[str, str]:
|
|
234
|
+
"""The Directive token when one is in scope, else the signed Runner envelope."""
|
|
235
|
+
|
|
236
|
+
token = directive_token.get()
|
|
237
|
+
if token is not None:
|
|
238
|
+
return {"Authorization": f"Bearer {token}"}
|
|
239
|
+
if self._state is None:
|
|
240
|
+
return {}
|
|
241
|
+
return self._state.signed_headers(method, url, body)
|
|
242
|
+
|
|
243
|
+
async def _get(self, path: str) -> dict[str, Any]:
|
|
244
|
+
try:
|
|
245
|
+
url = f"{self._base_url}{path}"
|
|
246
|
+
response = await self._http_client.get(
|
|
247
|
+
url, headers=self._credential_headers("GET", url, b"")
|
|
248
|
+
)
|
|
249
|
+
except httpx.HTTPError:
|
|
250
|
+
raise WorkerFastApiClientError(
|
|
251
|
+
f"FastAPI request failed: GET {path} could not complete",
|
|
252
|
+
method="GET",
|
|
253
|
+
path=path,
|
|
254
|
+
) from None
|
|
255
|
+
|
|
256
|
+
if not 200 <= response.status_code < 300:
|
|
257
|
+
raise _error_class_for_status(response.status_code)(
|
|
258
|
+
f"FastAPI request failed: GET {path} returned {response.status_code}",
|
|
259
|
+
method="GET",
|
|
260
|
+
path=path,
|
|
261
|
+
status_code=response.status_code,
|
|
262
|
+
detail=_response_error_detail(response),
|
|
263
|
+
) from None
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
response_payload = response.json()
|
|
267
|
+
except ValueError:
|
|
268
|
+
raise WorkerFastApiClientError(
|
|
269
|
+
f"FastAPI request failed: GET {path} returned invalid JSON"
|
|
270
|
+
) from None
|
|
271
|
+
|
|
272
|
+
if not isinstance(response_payload, dict):
|
|
273
|
+
raise WorkerFastApiClientError(
|
|
274
|
+
f"FastAPI request failed: GET {path} returned invalid JSON object"
|
|
275
|
+
) from None
|
|
276
|
+
|
|
277
|
+
return cast(dict[str, Any], response_payload)
|
|
278
|
+
|
|
279
|
+
async def _post(
|
|
280
|
+
self,
|
|
281
|
+
path: str,
|
|
282
|
+
payload: Mapping[str, Any],
|
|
283
|
+
*,
|
|
284
|
+
headers: Mapping[str, str] | None = None,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
# Serialised once, here: the Runner signature is over these exact bytes.
|
|
287
|
+
body = json.dumps(dict(payload)).encode("utf-8")
|
|
288
|
+
url = f"{self._base_url}{path}"
|
|
289
|
+
try:
|
|
290
|
+
response = await self._http_client.post(
|
|
291
|
+
url,
|
|
292
|
+
content=body,
|
|
293
|
+
headers={
|
|
294
|
+
"content-type": "application/json",
|
|
295
|
+
**self._credential_headers("POST", url, body),
|
|
296
|
+
**(headers or {}),
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
except httpx.HTTPError:
|
|
300
|
+
raise WorkerFastApiClientError(
|
|
301
|
+
f"FastAPI request failed: POST {path} could not complete",
|
|
302
|
+
method="POST",
|
|
303
|
+
path=path,
|
|
304
|
+
) from None
|
|
305
|
+
|
|
306
|
+
if not 200 <= response.status_code < 300:
|
|
307
|
+
raise _error_class_for_status(response.status_code)(
|
|
308
|
+
f"FastAPI request failed: POST {path} returned {response.status_code}",
|
|
309
|
+
method="POST",
|
|
310
|
+
path=path,
|
|
311
|
+
status_code=response.status_code,
|
|
312
|
+
detail=_response_error_detail(response),
|
|
313
|
+
) from None
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
response_payload = response.json()
|
|
317
|
+
except ValueError:
|
|
318
|
+
raise WorkerFastApiClientError(
|
|
319
|
+
f"FastAPI request failed: POST {path} returned invalid JSON"
|
|
320
|
+
) from None
|
|
321
|
+
|
|
322
|
+
if not isinstance(response_payload, dict):
|
|
323
|
+
raise WorkerFastApiClientError(
|
|
324
|
+
f"FastAPI request failed: POST {path} returned invalid JSON object"
|
|
325
|
+
) from None
|
|
326
|
+
|
|
327
|
+
return cast(dict[str, Any], response_payload)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _path_segment(value: str) -> str:
|
|
331
|
+
return quote(value, safe="")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _response_error_detail(response: httpx.Response) -> object | None:
|
|
335
|
+
try:
|
|
336
|
+
payload = response.json()
|
|
337
|
+
except ValueError:
|
|
338
|
+
return None
|
|
339
|
+
if not isinstance(payload, dict):
|
|
340
|
+
return None
|
|
341
|
+
return _sanitize_error_detail(payload.get("detail"))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _sanitize_error_detail(detail: object) -> object | None:
|
|
345
|
+
if isinstance(detail, list):
|
|
346
|
+
return [_sanitize_error_detail_item(item) for item in detail]
|
|
347
|
+
if isinstance(detail, dict):
|
|
348
|
+
invalid_transition_detail = _sanitize_invalid_transition_detail(detail)
|
|
349
|
+
if invalid_transition_detail is not None:
|
|
350
|
+
return invalid_transition_detail
|
|
351
|
+
return _sanitize_error_detail_item(detail)
|
|
352
|
+
if isinstance(detail, str):
|
|
353
|
+
return "<redacted>"
|
|
354
|
+
if isinstance(detail, int | float | bool) or detail is None:
|
|
355
|
+
return detail
|
|
356
|
+
return "<redacted>"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _sanitize_invalid_transition_detail(detail: dict[object, object]) -> dict[str, object] | None:
|
|
360
|
+
if detail.get("code") != _INVALID_WORK_RECORD_TRANSITION_CODE:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
from_state = detail.get("from_state")
|
|
364
|
+
to_state = detail.get("to_state")
|
|
365
|
+
allowed_states = detail.get("allowed_states")
|
|
366
|
+
if not isinstance(from_state, str):
|
|
367
|
+
return None
|
|
368
|
+
if not isinstance(to_state, str):
|
|
369
|
+
return None
|
|
370
|
+
if not isinstance(allowed_states, list):
|
|
371
|
+
return None
|
|
372
|
+
if not all(isinstance(state, str) for state in allowed_states):
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
"code": _INVALID_WORK_RECORD_TRANSITION_CODE,
|
|
377
|
+
"from_state": from_state,
|
|
378
|
+
"to_state": to_state,
|
|
379
|
+
"allowed_states": allowed_states,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _sanitize_error_detail_item(item: object) -> object:
|
|
384
|
+
if not isinstance(item, dict):
|
|
385
|
+
return "<redacted>"
|
|
386
|
+
|
|
387
|
+
sanitized: dict[str, object] = {}
|
|
388
|
+
for key in ("type", "loc", "msg"):
|
|
389
|
+
value = item.get(key)
|
|
390
|
+
if isinstance(value, str | int | float | bool) or value is None:
|
|
391
|
+
sanitized[key] = value
|
|
392
|
+
elif isinstance(value, list | tuple):
|
|
393
|
+
sanitized[key] = [str(part) for part in value]
|
|
394
|
+
else:
|
|
395
|
+
sanitized[key] = "<redacted>"
|
|
396
|
+
return sanitized
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Pull one Directive's usage event out of a harness's own stdout (PRD issue 31, 17 A9).
|
|
2
|
+
|
|
3
|
+
A device-login or setup-token Directive never touches the LLM proxy, so this is the only
|
|
4
|
+
place the Runner can read what it spent. Pure text parsing, no I/O: the worker calls this
|
|
5
|
+
on the ``DirectiveResult.stdout`` it already has and posts what comes back to
|
|
6
|
+
``/api/runner/v1/usage/harness`` (``workers/fastapi_client.py``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def extract_codex_turn_usage(stdout: str) -> dict[str, Any] | None:
|
|
16
|
+
"""The last ``turn.completed`` event's ``usage`` object from ``codex exec --json``.
|
|
17
|
+
|
|
18
|
+
JSONL, one event per line (research/29 §1.6); the *last* ``turn.completed`` is used
|
|
19
|
+
because a Directive may retry a turn internally and only the final one billed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
usage: dict[str, Any] | None = None
|
|
23
|
+
for line in stdout.splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
event = json.loads(line)
|
|
29
|
+
except ValueError:
|
|
30
|
+
continue
|
|
31
|
+
if isinstance(event, dict) and event.get("type") == "turn.completed":
|
|
32
|
+
candidate = event.get("usage")
|
|
33
|
+
if isinstance(candidate, dict):
|
|
34
|
+
usage = candidate
|
|
35
|
+
return usage
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_claude_result(stdout: str) -> dict[str, Any] | None:
|
|
39
|
+
"""The ``result`` message from ``claude -p --output-format json|stream-json``.
|
|
40
|
+
|
|
41
|
+
``json`` mode prints one object; ``stream-json`` prints one JSON value per line and
|
|
42
|
+
the ``result`` message is always last (research/29 §2.6) -- so trying whole-stdout
|
|
43
|
+
first and falling back to the last non-empty line covers both.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
stripped = stdout.strip()
|
|
47
|
+
if stripped:
|
|
48
|
+
try:
|
|
49
|
+
whole = json.loads(stripped)
|
|
50
|
+
except ValueError:
|
|
51
|
+
whole = None
|
|
52
|
+
if isinstance(whole, dict) and whole.get("type") == "result":
|
|
53
|
+
return whole
|
|
54
|
+
for line in reversed(stdout.splitlines()):
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
event = json.loads(line)
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
if isinstance(event, dict) and event.get("type") == "result":
|
|
63
|
+
return event
|
|
64
|
+
break
|
|
65
|
+
return None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The granted MCP servers, in each CLI's native config shape (PRD issue 58, map 05).
|
|
2
|
+
|
|
3
|
+
Both runtimes consume MCP natively, so there is no shim: the Runner decides which servers
|
|
4
|
+
the Agent's Effective Grant lets it have (``agentic_runner.mcp``) and each runtime writes
|
|
5
|
+
exactly those into its own config -- Codex as ``mcp_servers`` tables, Claude Code as
|
|
6
|
+
``--mcp-config`` JSON. MCP cannot list a server while hiding its tools, so a server that is
|
|
7
|
+
not granted is simply never written.
|
|
8
|
+
|
|
9
|
+
Codex's copy is handed over as a ``--config mcp_servers=<inline table>`` override on the
|
|
10
|
+
``codex exec`` line rather than written into the Contract's ``config.toml``: two Agents of
|
|
11
|
+
one Contract share that file (ADR-0015 §4), and with N Directives in flight per Runner
|
|
12
|
+
(ADR-0013 §8) one Agent's servers would be read by the other's Directive.
|
|
13
|
+
|
|
14
|
+
That override does *not* replace the table: Codex merges it key by key with every config
|
|
15
|
+
layer it loads, so a ``[mcp_servers.*]`` in the harness root's ``config.toml`` or in a
|
|
16
|
+
Workspace ``.codex/config.toml`` still starts beside the Runner's (the LA-02 spike, then
|
|
17
|
+
re-run on the pinned codex-cli 0.141.0 for LA-19). ``codex_mcp_argv`` therefore closes both
|
|
18
|
+
layers as well:
|
|
19
|
+
|
|
20
|
+
- ``--ignore-user-config`` skips ``$CODEX_HOME/config.toml``; auth still reads
|
|
21
|
+
``CODEX_HOME``. The Runner writes nothing there, so nothing it relies on is lost.
|
|
22
|
+
- ``projects.<workspace>.trust_level="untrusted"`` keeps Codex from loading the Workspace's
|
|
23
|
+
``.codex/config.toml`` (and any nested one below it). ``--ignore-user-config`` alone is
|
|
24
|
+
not enough: with a ``config.toml`` that does not parse -- which an earlier Directive, running
|
|
25
|
+
as the same Contract uid, can write -- 0.141.0 falls back to loading the Workspace layer.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from collections.abc import Iterable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
__all__ = ["McpServerEntry", "claude_mcp_config", "codex_mcp_argv", "codex_mcp_override"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class McpServerEntry:
|
|
40
|
+
"""One server as a CLI is told about it: a command it spawns, or a URL it calls.
|
|
41
|
+
|
|
42
|
+
``bearer_token_env`` names the environment variable holding the attempt's bearer --
|
|
43
|
+
the *name*, so the token itself never lands in an argv or on disk. Never carries a
|
|
44
|
+
credential: a server that needs one is Runner-hosted and reached by ``url``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
slug: str
|
|
48
|
+
command: str | None = None
|
|
49
|
+
args: tuple[str, ...] = ()
|
|
50
|
+
url: str | None = None
|
|
51
|
+
bearer_token_env: str | None = None
|
|
52
|
+
required: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _toml_string(value: str) -> str:
|
|
56
|
+
# A JSON string is a valid TOML basic string: the same quote and the same escapes.
|
|
57
|
+
return json.dumps(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _codex_table(entry: McpServerEntry) -> str:
|
|
61
|
+
fields: list[str] = []
|
|
62
|
+
if entry.url is not None:
|
|
63
|
+
fields.append(f"url={_toml_string(entry.url)}")
|
|
64
|
+
if entry.bearer_token_env is not None:
|
|
65
|
+
fields.append(f"bearer_token_env_var={_toml_string(entry.bearer_token_env)}")
|
|
66
|
+
else:
|
|
67
|
+
fields.append(f"command={_toml_string(entry.command or '')}")
|
|
68
|
+
fields.append("args=[" + ",".join(_toml_string(arg) for arg in entry.args) + "]")
|
|
69
|
+
if entry.required:
|
|
70
|
+
# Codex aborts the run when a required server fails to initialise -- the
|
|
71
|
+
# registry row's promise that the Directive is meaningless without it.
|
|
72
|
+
fields.append("required=true")
|
|
73
|
+
return f"{entry.slug}={{{','.join(fields)}}}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def codex_mcp_override(entries: Iterable[McpServerEntry]) -> str:
|
|
77
|
+
"""``mcp_servers=<inline table>`` for ``codex exec --config``; ``{}`` when none."""
|
|
78
|
+
|
|
79
|
+
return "mcp_servers={" + ",".join(_codex_table(entry) for entry in entries) + "}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def codex_mcp_argv(entries: Iterable[McpServerEntry], workspace_path: Path) -> list[str]:
|
|
83
|
+
"""The ``codex exec`` arguments under which exactly ``entries`` start, and nothing else."""
|
|
84
|
+
|
|
85
|
+
untrusted = f'projects={{{_toml_string(str(workspace_path))}={{trust_level="untrusted"}}}}'
|
|
86
|
+
return [
|
|
87
|
+
"--ignore-user-config",
|
|
88
|
+
"--config",
|
|
89
|
+
untrusted,
|
|
90
|
+
"--config",
|
|
91
|
+
codex_mcp_override(entries),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def claude_mcp_config(entries: Iterable[McpServerEntry]) -> str:
|
|
96
|
+
"""The inline JSON ``claude --mcp-config`` takes.
|
|
97
|
+
|
|
98
|
+
A header value's ``${VAR}`` is expanded by Claude Code from its own environment, which
|
|
99
|
+
is how the attempt's bearer reaches a Runner-hosted server without entering the argv.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
servers: dict[str, dict[str, object]] = {}
|
|
103
|
+
for entry in entries:
|
|
104
|
+
if entry.url is not None:
|
|
105
|
+
server: dict[str, object] = {"type": "http", "url": entry.url}
|
|
106
|
+
if entry.bearer_token_env is not None:
|
|
107
|
+
server["headers"] = {"Authorization": f"Bearer ${{{entry.bearer_token_env}}}"}
|
|
108
|
+
else:
|
|
109
|
+
server = {"type": "stdio", "command": entry.command or "", "args": list(entry.args)}
|
|
110
|
+
servers[entry.slug] = server
|
|
111
|
+
return json.dumps({"mcpServers": servers}, separators=(",", ":"))
|