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,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=(",", ":"))