agentic-runner 3.11.0__tar.gz → 3.12.1__tar.gz

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 (74) hide show
  1. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/PKG-INFO +1 -1
  2. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/service.py +38 -4
  4. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/acp_runtime.py +155 -17
  5. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/settings.py +3 -1
  6. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/.gitignore +0 -0
  7. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/LICENSE +0 -0
  8. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/README.md +0 -0
  9. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/pyproject.toml +0 -0
  10. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/activities.py +0 -0
  11. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/attempts.py +0 -0
  12. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/auth_mode.py +0 -0
  13. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/build.py +0 -0
  14. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/callback.py +0 -0
  15. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/child_watcher.py +0 -0
  16. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/cli.py +0 -0
  17. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/cli_floor.py +0 -0
  18. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/config.py +0 -0
  19. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/credentials.py +0 -0
  20. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/device_login_activities.py +0 -0
  21. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/egress.py +0 -0
  22. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/harness_self_test.py +0 -0
  23. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/heartbeat_link.py +0 -0
  24. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/hooks.py +0 -0
  25. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/host_store.py +0 -0
  26. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/__init__.py +0 -0
  27. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  28. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  29. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  30. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  31. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  32. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  33. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  34. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  35. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  36. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  37. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  38. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/llm_proxy.py +0 -0
  40. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/mcp.py +0 -0
  41. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/message_store.py +0 -0
  42. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/oauth_connectors.py +0 -0
  43. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/private_state.py +0 -0
  44. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/py.typed +0 -0
  45. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  46. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/registration.py +0 -0
  47. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/runtime/__init__.py +0 -0
  48. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
  49. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/sealed_box.py +0 -0
  50. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/testing/__init__.py +0 -0
  51. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/testing/__main__.py +0 -0
  52. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/testing/control_plane.py +0 -0
  53. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/testing/plugin.py +0 -0
  54. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/testing/test_conformance.py +0 -0
  55. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/testing/workflow.py +0 -0
  56. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/tiny_http.py +0 -0
  57. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/triage_activities.py +0 -0
  58. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/usage_windows.py +0 -0
  59. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/user_sources.py +0 -0
  60. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/__init__.py +0 -0
  61. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
  62. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
  63. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
  64. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  65. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
  66. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/command_policy.py +0 -0
  67. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/contract_device_login.py +0 -0
  68. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
  69. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
  70. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/harness_outcome.py +0 -0
  71. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  72. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  73. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workers/skills.py +0 -0
  74. {agentic_runner-3.11.0 → agentic_runner-3.12.1}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.11.0
3
+ Version: 3.12.1
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.11.0"
10
+ __version__ = "3.12.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -121,6 +121,7 @@ from agentic_runner_contracts.channel_messages import TranscriptDelivery
121
121
  from agentic_runner_contracts.routing import RunnerRoutingIdentity
122
122
  from agentic_runner_contracts.runner_registration import (
123
123
  RECENT_OUTCOMES_MAX,
124
+ AcpBridgeVersion,
124
125
  CliVersion,
125
126
  DirectiveOutcome,
126
127
  EgressPosture,
@@ -411,13 +412,12 @@ def build_agent_runtimes(
411
412
  local-agents 18) reaches `clis` through :func:`with_profile_acp_cli_kinds`.
412
413
  """
413
414
 
414
- acp_kinds = {kind.strip() for kind in settings.ACP_CLI_KINDS.split(",") if kind.strip()}
415
+ pinned_kinds = _pinned_acp_cli_kinds(settings)
415
416
  profile_kinds = set(profile_acp_executables(settings))
416
417
  return {
417
418
  cli.cli_kind: (
418
419
  AcpRuntime(cli_kind=cli.cli_kind, settings=settings)
419
- if cli.cli_kind in profile_kinds
420
- or (cli.cli_kind in acp_kinds and cli.cli_kind in ACP_BRIDGES)
420
+ if cli.cli_kind in profile_kinds or cli.cli_kind in pinned_kinds
421
421
  else _RUNTIMES[cli.cli_kind](settings)
422
422
  )
423
423
  for cli in clis
@@ -425,6 +425,40 @@ def build_agent_runtimes(
425
425
  }
426
426
 
427
427
 
428
+ def _pinned_acp_cli_kinds(settings: WorkerSettings) -> set[str]:
429
+ """The kinds `ACP_CLI_KINDS` routes through a bridge this Runner pins."""
430
+
431
+ named = {kind.strip() for kind in settings.ACP_CLI_KINDS.split(",") if kind.strip()}
432
+ return named & ACP_BRIDGES.keys()
433
+
434
+
435
+ def with_acp_bridges(
436
+ attestation: HostAttestation | None, settings: WorkerSettings
437
+ ) -> HostAttestation | None:
438
+ """The attestation, with each kind this Runner serves through its pinned ACP bridge
439
+ carrying that bridge's package and version (QTS-1322), so the console shows what
440
+ actually drives the CLI. A kind below its floor is not served, so it names no bridge.
441
+ """
442
+
443
+ if attestation is None:
444
+ return None
445
+ pinned_kinds = _pinned_acp_cli_kinds(settings)
446
+ clis = [
447
+ cli.model_copy(
448
+ update={
449
+ "acp_bridge": AcpBridgeVersion(
450
+ package=ACP_BRIDGES[cli.cli_kind].package,
451
+ version=ACP_BRIDGES[cli.cli_kind].version,
452
+ )
453
+ }
454
+ )
455
+ if cli.meets_floor and cli.cli_kind in pinned_kinds
456
+ else cli
457
+ for cli in attestation.clis
458
+ ]
459
+ return attestation.model_copy(update={"clis": clis})
460
+
461
+
428
462
  def with_profile_acp_cli_kinds(
429
463
  attestation: HostAttestation | None, settings: WorkerSettings
430
464
  ) -> HostAttestation | None:
@@ -958,7 +992,7 @@ async def run(
958
992
  os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
959
993
  settings = get_worker_settings()
960
994
  try:
961
- attestation = with_profile_acp_cli_kinds(attestation, settings)
995
+ attestation = with_profile_acp_cli_kinds(with_acp_bridges(attestation, settings), settings)
962
996
  except ValueError as error:
963
997
  print(f"refusing to start: {error}", flush=True)
964
998
  return 1
@@ -34,7 +34,19 @@ Facts this module stands on (the LA-02 spike, re-read against the pinned bridge
34
34
  is refused, and a harness-root ``config.toml`` may hold only ``_CODEX_HARNESS_ROOT_KEYS``
35
35
  (LA-19): nothing may load that the Runner did not choose.
36
36
  - claude-agent-acp loads the Workspace's settings, hooks, ``.mcp.json`` and ``CLAUDE.md``
37
- unless ``session/new`` carries the three options in ``_CLAUDE_SESSION_OPTIONS``.
37
+ unless ``session/new`` carries the three options in ``_CLAUDE_SESSION_OPTIONS``. The
38
+ harness root is the Contract's uid's to write, and its ``user`` source is more than
39
+ ``settings.json``: a skill's or command's ``allowed-tools`` is an allow rule, and an
40
+ agent, a hook or ``CLAUDE.md`` would steer every later Directive of that Contract. So no
41
+ setting source loads at all, the ``user`` one included: measured on claude 2.1.280, the
42
+ harness root's skills, commands, agents, ``CLAUDE.md`` and settings stay out while its
43
+ ``.credentials.json`` login still signs the requests (LA-12d item 2). The bridge itself
44
+ still reads the harness root's ``settings.json`` for the initial mode, bypass and model
45
+ list, so that file may hold only ``_CLAUDE_HARNESS_ROOT_KEYS``.
46
+ - A Claude permission request for an MCP tool names no command. It carries the serving
47
+ server in ``_meta.claudeCode.mcpServer``, and ``source: "dynamic"`` is a server from the
48
+ CLI's ``--mcp-config``: the ones ``session/new`` sent, the only ones ``strictMcpConfig``
49
+ loads. A granted server's tool is allowed (LA-12d item 1).
38
50
 
39
51
  A ``cli_kind`` with no pinned bridge (local-agents 18) runs the command its Agent Runtime
40
52
  Profile names, and only on a Runner whose host pinned that kind's executable in
@@ -187,9 +199,21 @@ _CODEX_HARNESS_ROOT_KEYS: Final[frozenset[str]] = frozenset(
187
199
  {"model", "model_reasoning_effort", "model_reasoning_summary", "model_verbosity", "notice"}
188
200
  )
189
201
 
202
+ # What a harness-root `settings.json` may set: the choices Claude Code writes there itself.
203
+ # The CLI no longer loads it, but the bridge does; `permissions`, `hooks`, `env`,
204
+ # `apiKeyHelper`, `enabledPlugins` and the rest would be the Contract's own file steering it.
205
+ _CLAUDE_HARNESS_ROOT_KEYS: Final[frozenset[str]] = frozenset(
206
+ {"$schema", "model", "effortLevel", "alwaysThinkingEnabled"}
207
+ )
208
+ # The `mcpServer.source` of a server the CLI got from `--mcp-config`, which is where the
209
+ # bridge puts `session/new`'s servers. A name alone is the config key as authored, so a
210
+ # server from any other source could reuse a granted slug.
211
+ _CLAUDE_SENT_MCP_SOURCE: Final[str] = "dynamic"
212
+
190
213
  _CLAUDE_SESSION_OPTIONS: Final[Mapping[str, object]] = {
191
- # The harness root's own settings only: no Workspace settings, hooks or CLAUDE.md.
192
- "settingSources": ["user"],
214
+ # No settings file, hook, skill, command, agent or CLAUDE.md from anywhere, the
215
+ # Contract's harness root included; the login is not a setting source and still loads.
216
+ "settingSources": [],
193
217
  # Only the servers sent over ACP; never the Workspace's `.mcp.json`.
194
218
  "strictMcpConfig": True,
195
219
  # Bypass is never offered as a mode, whatever a settings file asks for.
@@ -267,6 +291,7 @@ class _Turn:
267
291
  output: list[str] = field(default_factory=list)
268
292
  notes: list[str] = field(default_factory=list)
269
293
  held: bool = False
294
+ session_id: str = ""
270
295
  usage_windows: tuple[UsageWindow, ...] = ()
271
296
 
272
297
 
@@ -389,6 +414,10 @@ class AcpRuntime:
389
414
  )
390
415
  if config_refusal:
391
416
  return refuse("refused: Codex config file", config_refusal)
417
+ if self._is_claude and harness_root is not None:
418
+ settings_refusal = _claude_settings_refusal(harness_root)
419
+ if settings_refusal:
420
+ return refuse("refused: Claude settings file", settings_refusal)
392
421
 
393
422
  # The activity refuses this first, with Evidence; checked again where it runs.
394
423
  command_refusal = self.acp_command_refusal(request.acp_command)
@@ -539,7 +568,7 @@ class AcpRuntime:
539
568
  with recorded_group(process.pid):
540
569
  try:
541
570
  stop_reason, model_usage, error = await asyncio.wait_for(
542
- self._turn(connection, request, workspace_path, extra_env),
571
+ self._turn(connection, request, workspace_path, extra_env, turn),
543
572
  timeout=self._timeout_seconds,
544
573
  )
545
574
  finally:
@@ -551,6 +580,17 @@ class AcpRuntime:
551
580
  await terminate_process_tree(process)
552
581
  raise
553
582
  stderr = await stderr_task
583
+ if self._is_codex and turn.session_id:
584
+ # After the bridge exited, so Codex has flushed the rollout, and before the
585
+ # api_key attempt root holding it is removed.
586
+ codex_home = Path(env[ACP_BRIDGES["codex_cli"].config_root_env])
587
+ total = await asyncio.to_thread(
588
+ _codex_rollout_usage, codex_home, turn.session_id, model_usage
589
+ )
590
+ if total is None:
591
+ turn.notes.append("usage: the bridge's last model call only; no rollout total")
592
+ else:
593
+ model_usage = total
554
594
  return stop_reason, model_usage, stderr, error
555
595
 
556
596
  async def _turn(
@@ -559,6 +599,7 @@ class AcpRuntime:
559
599
  request: DirectiveRequest,
560
600
  workspace_path: Path,
561
601
  extra_env: Mapping[str, str],
602
+ turn: _Turn,
562
603
  ) -> tuple[str, tuple[ModelUsage, ...], str]:
563
604
  try:
564
605
  await connection.request(
@@ -584,7 +625,7 @@ class AcpRuntime:
584
625
  }
585
626
  }
586
627
  session = await connection.request("session/new", session_params)
587
- session_id = str(session.get("sessionId") or "")
628
+ session_id = turn.session_id = str(session.get("sessionId") or "")
588
629
  if self._is_claude and _offers_mode(session, _CLAUDE_MODE):
589
630
  await connection.request(
590
631
  "session/set_mode", {"sessionId": session_id, "modeId": _CLAUDE_MODE}
@@ -625,19 +666,22 @@ class AcpRuntime:
625
666
  turn: _Turn,
626
667
  ) -> Mapping[str, Any]:
627
668
  tool_call = params.get("toolCall") or {}
628
- raw_input = tool_call.get("rawInput") if isinstance(tool_call, Mapping) else None
629
- argv, cwd = _requested_command(raw_input, workspace_path)
630
- described = (
631
- shlex.join(argv)
632
- if argv
633
- else str(tool_call.get("title") or "an action")
634
- if isinstance(tool_call, Mapping)
635
- else "an action"
669
+ title = str(tool_call.get("title") or "") if isinstance(tool_call, Mapping) else ""
670
+ mcp_server = (
671
+ _granted_mcp_server(tool_call, request.mcp_servers or ()) if self._is_claude else None
636
672
  )
637
- described = _redact(described)
638
- # The Directive's own Workspace, not WORKSPACE_ROOT: without a Contract uid nothing
639
- # else keeps an allowlisted command out of another Work Record's Workspace.
640
- verdict, reason = _verdict(argv, cwd, request, workspace_path)
673
+ if mcp_server is not None:
674
+ # Checked first: an MCP tool's arguments are not a command, even one named so.
675
+ described = _redact(f"{title} (MCP server {mcp_server})")
676
+ verdict, reason = _Verdict.ALLOW, "a granted MCP server's tool"
677
+ else:
678
+ raw_input = tool_call.get("rawInput") if isinstance(tool_call, Mapping) else None
679
+ argv, cwd = _requested_command(raw_input, workspace_path)
680
+ described = _redact(shlex.join(argv) if argv else title or "an action")
681
+ # The Directive's own Workspace, not WORKSPACE_ROOT: without a Contract uid
682
+ # nothing else keeps an allowlisted command out of another Work Record's
683
+ # Workspace.
684
+ verdict, reason = _verdict(argv, cwd, request, workspace_path)
641
685
  options = params.get("options") or []
642
686
  if verdict == _Verdict.ALLOW:
643
687
  turn.notes.append(f"permission allowed: {described}")
@@ -879,6 +923,44 @@ def _codex_config_refusal(workspace_path: Path, harness_root: Path | None) -> st
879
923
  return None
880
924
 
881
925
 
926
+ def _claude_settings_refusal(harness_root: Path) -> str | None:
927
+ """Why the harness root's ``settings.json`` refuses a Claude Directive, or None.
928
+
929
+ As ``_codex_config_refusal``: the key names, never the path, reach the control plane.
930
+ """
931
+
932
+ try:
933
+ text = (harness_root / "settings.json").read_text(encoding="utf-8")
934
+ except FileNotFoundError:
935
+ return None
936
+ except OSError:
937
+ return "the harness root's settings.json cannot be read"
938
+ try:
939
+ settings = json.loads(text)
940
+ except ValueError:
941
+ return "the harness root's settings.json does not parse"
942
+ if not isinstance(settings, dict):
943
+ return "the harness root's settings.json is not an object"
944
+ unexpected = sorted(set(settings) - _CLAUDE_HARNESS_ROOT_KEYS)
945
+ if unexpected:
946
+ return f"the harness root's settings.json sets {', '.join(unexpected)}; remove them"
947
+ return None
948
+
949
+
950
+ def _granted_mcp_server(tool_call: object, granted: Sequence[McpServerEntry]) -> str | None:
951
+ """The granted server a Claude permission request's MCP tool is served by, or None."""
952
+
953
+ if not isinstance(tool_call, Mapping):
954
+ return None
955
+ meta = tool_call.get("_meta")
956
+ claude = meta.get("claudeCode") if isinstance(meta, Mapping) else None
957
+ server = claude.get("mcpServer") if isinstance(claude, Mapping) else None
958
+ if not isinstance(server, Mapping) or server.get("source") != _CLAUDE_SENT_MCP_SOURCE:
959
+ return None
960
+ name = server.get("name")
961
+ return name if isinstance(name, str) and name in {e.slug for e in granted} else None
962
+
963
+
882
964
  @contextlib.contextmanager
883
965
  def _attempt_codex_home(
884
966
  proxy_url: str, harness_root: Path, sandbox: DirectiveSandbox | None
@@ -1034,6 +1116,62 @@ def _model_usage(response: Mapping[str, Any]) -> tuple[ModelUsage, ...]:
1034
1116
  return tuple(usage)
1035
1117
 
1036
1118
 
1119
+ def _codex_rollout_usage(
1120
+ codex_home: Path, session_id: str, reported: tuple[ModelUsage, ...]
1121
+ ) -> tuple[ModelUsage, ...] | None:
1122
+ """The whole turn's usage from the Codex thread's rollout, or None if it has none.
1123
+
1124
+ codex-acp 2.1.1 reports ``tokenUsage.last`` -- the turn's last model call -- as the
1125
+ prompt response's ``model_usage`` (LA-12d item 4). One Directive is one session with
1126
+ one turn, so the thread's ``total_token_usage`` is that turn's. The ACP session id is
1127
+ the Codex thread id, which names the rollout file.
1128
+ """
1129
+
1130
+ # The id goes into a glob, and a subscription harness root keeps other Directives'
1131
+ # rollouts: anything but a thread id could match, and meter, one of theirs.
1132
+ try:
1133
+ uuid.UUID(session_id)
1134
+ except ValueError:
1135
+ return None
1136
+ total: Mapping[str, Any] | None = None
1137
+ model = reported[0].model if reported else None
1138
+ try:
1139
+ for path in (codex_home / "sessions").rglob(f"rollout-*-{session_id}.jsonl"):
1140
+ with path.open(encoding="utf-8") as rollout:
1141
+ for line in rollout:
1142
+ try:
1143
+ entry = json.loads(line)
1144
+ except ValueError:
1145
+ continue
1146
+ payload = entry.get("payload") if isinstance(entry, dict) else None
1147
+ if not isinstance(payload, dict):
1148
+ continue
1149
+ if entry.get("type") == "turn_context" and isinstance(
1150
+ payload.get("model"), str
1151
+ ):
1152
+ model = model or payload["model"]
1153
+ info = payload.get("info") if payload.get("type") == "token_count" else None
1154
+ usage = info.get("total_token_usage") if isinstance(info, dict) else None
1155
+ if isinstance(usage, dict):
1156
+ total = usage
1157
+ except OSError:
1158
+ return None
1159
+ if total is None or model is None:
1160
+ return None
1161
+ cached = _count(total, "cached_input_tokens")
1162
+ return (
1163
+ ModelUsage(
1164
+ model=model,
1165
+ # Net of the cached part, as the bridge's own count is.
1166
+ input_tokens=max(_count(total, "input_tokens") - cached, 0),
1167
+ output_tokens=_count(total, "output_tokens"),
1168
+ cached_read_tokens=cached,
1169
+ cached_write_tokens=_count(total, "cache_write_input_tokens"),
1170
+ reasoning_output_tokens=_count(total, "reasoning_output_tokens"),
1171
+ ),
1172
+ )
1173
+
1174
+
1037
1175
  def _count(values: Mapping[str, Any], key: str) -> int:
1038
1176
  value = values.get(key)
1039
1177
  return int(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0
@@ -137,7 +137,9 @@ class WorkerSettings(BaseSettings):
137
137
  default="",
138
138
  description=(
139
139
  "Comma-separated cli_kinds (`codex_cli`, `claude_code`) this Runner serves through "
140
- "the pinned ACP bridge instead of the per-CLI runtime (local-agents 12)."
140
+ "the pinned ACP bridge instead of the per-CLI runtime (local-agents 12). Never "
141
+ "`codex_cli` on a Runner pod: its bwrap sandbox cannot start under the default "
142
+ "seccomp profile (LA-12d item 3)."
141
143
  ),
142
144
  )
143
145
  ACP_PROFILE_CLI_KINDS: str = Field(
File without changes