agentic-runner 3.3.0__tar.gz → 3.4.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 (68) hide show
  1. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/PKG-INFO +1 -1
  2. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/activities.py +115 -1
  4. agentic_runner-3.4.1/src/agentic_runner/cli_floor.py +33 -0
  5. agentic_runner-3.4.1/src/agentic_runner/harness_self_test.py +162 -0
  6. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/service.py +81 -16
  7. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/agent_runtime.py +20 -0
  8. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/claude_runtime.py +12 -0
  9. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/codex_runtime.py +16 -0
  10. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/contract_isolation.py +64 -3
  11. agentic_runner-3.4.1/src/agentic_runner/workers/harness_outcome.py +213 -0
  12. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workstation.py +4 -20
  13. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/.gitignore +0 -0
  14. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/LICENSE +0 -0
  15. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/README.md +0 -0
  16. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/pyproject.toml +0 -0
  17. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/auth_mode.py +0 -0
  19. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/build.py +0 -0
  20. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/callback.py +0 -0
  21. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/child_watcher.py +0 -0
  22. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/cli.py +0 -0
  23. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/config.py +0 -0
  24. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/credentials.py +0 -0
  25. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/device_login_activities.py +0 -0
  26. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/egress.py +0 -0
  27. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/heartbeat_link.py +0 -0
  28. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/hooks.py +0 -0
  29. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/host_store.py +0 -0
  30. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/__init__.py +0 -0
  31. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  32. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  33. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  34. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  35. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  36. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  37. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  38. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  39. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  40. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/lifecycle.py +0 -0
  41. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/llm_proxy.py +0 -0
  42. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/mcp.py +0 -0
  43. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/message_store.py +0 -0
  44. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/private_state.py +0 -0
  45. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/py.typed +0 -0
  46. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  47. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/registration.py +0 -0
  48. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/runtime/__init__.py +0 -0
  49. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
  50. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/sealed_box.py +0 -0
  51. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/__init__.py +0 -0
  52. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/__main__.py +0 -0
  53. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/control_plane.py +0 -0
  54. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/plugin.py +0 -0
  55. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/test_conformance.py +0 -0
  56. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/testing/workflow.py +0 -0
  57. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/tiny_http.py +0 -0
  58. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/triage_activities.py +0 -0
  59. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/user_sources.py +0 -0
  60. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/__init__.py +0 -0
  61. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
  62. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/command_policy.py +0 -0
  63. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/contract_device_login.py +0 -0
  64. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
  65. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  66. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  67. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/settings.py +0 -0
  68. {agentic_runner-3.3.0 → agentic_runner-3.4.1}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.3.0
3
+ Version: 3.4.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.3.0"
10
+ __version__ = "3.4.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -31,6 +31,7 @@ import tempfile
31
31
  import time
32
32
  from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
33
33
  from dataclasses import asdict, dataclass, field, fields, replace
34
+ from datetime import UTC, datetime
34
35
  from pathlib import Path
35
36
  from typing import Any, NoReturn, Protocol
36
37
  from uuid import UUID
@@ -75,6 +76,7 @@ from agentic_runner.credentials import (
75
76
  UnresolvableCredentialReferenceError,
76
77
  )
77
78
  from agentic_runner.egress import EGRESS_REFUSED_SOURCE, EgressProxy, Resolver, host_of
79
+ from agentic_runner.harness_self_test import DirectivesInFlight
78
80
  from agentic_runner.heartbeat_link import HeartbeatLink, SeamUnavailableError
79
81
  from agentic_runner.hooks import (
80
82
  HOOK_EVIDENCE_SOURCE,
@@ -140,6 +142,7 @@ from agentic_runner.workers.fastapi_client import (
140
142
  WorkerFastApiClientError,
141
143
  directive_token,
142
144
  )
145
+ from agentic_runner.workers.harness_outcome import classify_harness_outcome
143
146
  from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
144
147
  from agentic_runner.workers.mcp_config import McpServerEntry
145
148
  from agentic_runner.workers.skills import (
@@ -168,6 +171,7 @@ from agentic_runner_contracts.activity_io import (
168
171
  FixDirectiveOutput,
169
172
  GitHubCallError,
170
173
  GitHubCallFailure,
174
+ HarnessHold,
171
175
  LearningDirectiveInput,
172
176
  LearningDirectiveOutput,
173
177
  MemberDirectiveInput,
@@ -1442,6 +1446,7 @@ DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
1442
1446
  AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
1443
1447
  SKILLS_EVIDENCE_SOURCE = "runner.skills"
1444
1448
  HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
1449
+ HARNESS_HOLD_SOURCE = "runner.harness_hold"
1445
1450
 
1446
1451
 
1447
1452
  class _EvidenceWriter(Protocol):
@@ -1555,8 +1560,11 @@ class RunnerRalphActivities:
1555
1560
  egress_resolver: Resolver | None = None,
1556
1561
  tool_server_health: ToolServerHealthLog | None = None,
1557
1562
  attempt_records: AttemptRecords | None = None,
1563
+ directives_in_flight: DirectivesInFlight | None = None,
1558
1564
  ) -> None:
1559
1565
  self._fastapi_client = fastapi_client
1566
+ # Read by the harness self-test, which never runs beside a Contract's Directive.
1567
+ self._directives_in_flight = directives_in_flight or DirectivesInFlight()
1560
1568
  # Wall-clock source for per-Directive runtime, accrued against the Budget's
1561
1569
  # wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
1562
1570
  # so a real monotonic clock is allowed here (it must never be used in the
@@ -2018,6 +2026,31 @@ class RunnerRalphActivities:
2018
2026
  auth_mode=attempt.auth_mode,
2019
2027
  )
2020
2028
  plan = _take_plan(checkout_workspace)
2029
+ harness_hold = await self._harness_hold(
2030
+ runtime_state=runtime_state,
2031
+ work_record_id=request.work_record_id,
2032
+ directive_number=request.directive_number,
2033
+ result=codex_result,
2034
+ auth_mode=attempt.auth_mode,
2035
+ )
2036
+ if harness_hold is not None:
2037
+ return BranchPullRequestOutput(
2038
+ repository=request.repository,
2039
+ branch_name=request.branch_name,
2040
+ base_ref=request.base_ref,
2041
+ pr_number=0,
2042
+ pr_url="",
2043
+ branch_created=branch.created,
2044
+ pr_created=False,
2045
+ duration_seconds=self._monotonic() - directive_started,
2046
+ usage=await self._directive_usage(
2047
+ work_record_id=request.work_record_id,
2048
+ directive_number=request.directive_number,
2049
+ ),
2050
+ workspace_path=str(workspace_path),
2051
+ grant_snapshot=dict(request.grant_snapshot),
2052
+ harness_hold=harness_hold,
2053
+ )
2021
2054
  if codex_result.exit_code != 0:
2022
2055
  if _is_guard_mode_refusal(codex_result):
2023
2056
  # The runtime refused this Directive (guard-mode). Return a flagged
@@ -2475,6 +2508,7 @@ class RunnerRalphActivities:
2475
2508
  scratch_root = sandbox.tmp_dir if sandbox is not None else Path(tempfile.gettempdir())
2476
2509
  attempt_dir = prepare_attempt_dir(scratch_root / f"attempt-{key}", uid)
2477
2510
  async with contextlib.AsyncExitStack() as stack:
2511
+ stack.enter_context(self._directives_in_flight.running(facts.contract_id))
2478
2512
  stack.callback(shutil.rmtree, attempt_dir, ignore_errors=True)
2479
2513
  callback_env: dict[str, str] = {}
2480
2514
  if self._socket_dir is not None:
@@ -4484,7 +4518,13 @@ class RunnerRalphActivities:
4484
4518
  guard_mode_refused=True,
4485
4519
  workspace_path=request.workspace_path,
4486
4520
  )
4487
- if consumed and self._message_store is not None and not ran.guard_mode_refused:
4521
+ if (
4522
+ consumed
4523
+ and self._message_store is not None
4524
+ and not ran.guard_mode_refused
4525
+ # A held turn never read its Messages; the wake after the hold must see them.
4526
+ and ran.harness_hold is None
4527
+ ):
4488
4528
  self._message_store.mark_consumed(
4489
4529
  contract_id=contract[0],
4490
4530
  work_record_id=request.work_record_id,
@@ -4616,6 +4656,13 @@ class RunnerRalphActivities:
4616
4656
  result=result,
4617
4657
  auth_mode=attempt.auth_mode,
4618
4658
  )
4659
+ harness_hold = await self._harness_hold(
4660
+ runtime_state=state,
4661
+ work_record_id=request.work_record_id,
4662
+ directive_number=request.directive_number,
4663
+ result=result,
4664
+ auth_mode=attempt.auth_mode,
4665
+ )
4619
4666
  usage = await self._directive_usage(
4620
4667
  work_record_id=request.work_record_id,
4621
4668
  directive_number=request.directive_number,
@@ -4652,6 +4699,7 @@ class RunnerRalphActivities:
4652
4699
  usage=usage,
4653
4700
  outcome=LEARNING_FAILED,
4654
4701
  summary=f"the Learner's runtime exited {result.exit_code}",
4702
+ harness_hold=harness_hold,
4655
4703
  )
4656
4704
  return self._learning_output(
4657
4705
  request,
@@ -4668,6 +4716,7 @@ class RunnerRalphActivities:
4668
4716
  usage: DirectiveUsage | None = None,
4669
4717
  outcome: str = LEARNING_PROPOSED,
4670
4718
  summary: str = "",
4719
+ harness_hold: HarnessHold | None = None,
4671
4720
  ) -> LearningDirectiveOutput:
4672
4721
  return LearningDirectiveOutput(
4673
4722
  work_record_id=request.work_record_id,
@@ -4676,6 +4725,7 @@ class RunnerRalphActivities:
4676
4725
  usage=usage or DirectiveUsage(),
4677
4726
  outcome=outcome,
4678
4727
  summary=summary,
4728
+ harness_hold=harness_hold,
4679
4729
  )
4680
4730
 
4681
4731
  def _learner_transcript(self, state: _RuntimeContextState, work_record_id: str) -> str:
@@ -4875,6 +4925,30 @@ class RunnerRalphActivities:
4875
4925
  )
4876
4926
  # Taken before the commit below so it never lands on the branch (PRD 59).
4877
4927
  plan = _take_plan(state.workspace_path)
4928
+ harness_hold = await self._harness_hold(
4929
+ runtime_state=state,
4930
+ work_record_id=request.work_record_id,
4931
+ directive_number=request.directive_number,
4932
+ result=codex_result,
4933
+ auth_mode=attempt.auth_mode,
4934
+ )
4935
+ if harness_hold is not None:
4936
+ return FixDirectiveOutput(
4937
+ work_record_id=request.work_record_id,
4938
+ repository=request.repository,
4939
+ pr_number=request.pr_number,
4940
+ directive_number=request.directive_number,
4941
+ branch_head_sha="",
4942
+ summary=f"held: the harness reported {harness_hold.kind.value}",
4943
+ duration_seconds=self._monotonic() - directive_started,
4944
+ usage=await self._directive_usage(
4945
+ work_record_id=request.work_record_id,
4946
+ directive_number=request.directive_number,
4947
+ ),
4948
+ workspace_path=str(state.workspace_path),
4949
+ grant_snapshot=state.grant_snapshot_payload,
4950
+ harness_hold=harness_hold,
4951
+ )
4878
4952
  if codex_result.exit_code != 0:
4879
4953
  if _is_guard_mode_refusal(codex_result):
4880
4954
  return FixDirectiveOutput(
@@ -5114,6 +5188,46 @@ class RunnerRalphActivities:
5114
5188
  }
5115
5189
  )
5116
5190
 
5191
+ async def _harness_hold(
5192
+ self,
5193
+ *,
5194
+ runtime_state: _RuntimeContextState,
5195
+ work_record_id: str,
5196
+ directive_number: int,
5197
+ result: DirectiveResult,
5198
+ auth_mode: AuthMode,
5199
+ ) -> HarnessHold | None:
5200
+ """A subscription harness that stopped on its usage limit or sign-in, recorded
5201
+ once (local-agents 08). An ``api_key`` Directive's limits are the proxy's, so it is
5202
+ never classified. The Evidence names the kind and the reset, never the output."""
5203
+
5204
+ if auth_mode != AuthMode.SUBSCRIPTION:
5205
+ return None
5206
+ hold = classify_harness_outcome(
5207
+ runtime_state.cli_kind,
5208
+ exit_code=result.exit_code,
5209
+ stdout=result.stdout,
5210
+ stderr=result.stderr,
5211
+ error=result.error,
5212
+ now=datetime.now(UTC),
5213
+ )
5214
+ if hold is None:
5215
+ return None
5216
+ await self._fastapi_client.append_evidence(
5217
+ work_record_id,
5218
+ source=HARNESS_HOLD_SOURCE,
5219
+ payload={
5220
+ "event": "directive.harness_hold",
5221
+ "kind": hold.kind.value,
5222
+ "cli_kind": runtime_state.cli_kind,
5223
+ "retry_not_before": hold.retry_not_before,
5224
+ "directive_number": directive_number,
5225
+ "agent_id": runtime_state.agent_id,
5226
+ "contract_id": runtime_state.contract_id,
5227
+ },
5228
+ )
5229
+ return hold
5230
+
5117
5231
  @activity.defn(name="wipe_contract_residue")
5118
5232
  async def wipe_contract_residue(self, request: ContractResidueInput) -> ContractResidueOutput:
5119
5233
  """Delete everything one Contract left on this Runner and retire its uid (17 A6).
@@ -0,0 +1,33 @@
1
+ """Which executable each Agent Runtime CLI is, and the version floor it must clear."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Final
8
+
9
+ __all__ = ["CLI_FLOORS", "CLI_NAMES", "meets_floor", "parse_cli_version"]
10
+
11
+ # What the Profile's `cli_kind` runs, by executable name on PATH.
12
+ CLI_NAMES: Final[Mapping[str, str]] = {"codex_cli": "codex", "claude_code": "claude"}
13
+ # The versions the Runner image is built and tested against (Dockerfile.runner's
14
+ # CODEX_CLI_VERSION / CLAUDE_CODE_VERSION; a test holds the two together). Below the
15
+ # floor is *reported*, not refused: the user's own CLI is the user's to upgrade.
16
+ CLI_FLOORS: Final[Mapping[str, str]] = {"codex": "0.141.0", "claude": "2.1.280"}
17
+
18
+ _VERSION = re.compile(r"(\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40})")
19
+
20
+
21
+ def parse_cli_version(output: str) -> str | None:
22
+ match = _VERSION.search(output)
23
+ return None if match is None else match.group(1)
24
+
25
+
26
+ def meets_floor(name: str, version: str) -> bool:
27
+ """Whether ``version`` of the executable ``name`` clears the Runner's floor for it."""
28
+
29
+ return _version_tuple(version) >= _version_tuple(CLI_FLOORS[name])
30
+
31
+
32
+ def _version_tuple(version: str) -> tuple[int, ...]:
33
+ return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
@@ -0,0 +1,162 @@
1
+ """A harness's ``--version``, run as each Contract would run it (local-agents 17).
2
+
3
+ What the heartbeat's ``cli`` attestation says is that the Runner's own uid can start a
4
+ CLI. Whether a Contract can is a different fact: its uid, its harness root and its
5
+ sandbox floor all stand between the two. So the self-test spawns exactly what a
6
+ Directive spawns, minus the prompt, and keeps only the verdict -- never the output, which
7
+ on a harness root can carry an account name.
8
+
9
+ At most once per interval per Contract and never while that Contract has a Directive
10
+ running: the harness root is the Directive's while it runs, and a concurrent spawn
11
+ under the same rlimit floor would count against the Directive's process ceiling.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ from collections import Counter
19
+ from collections.abc import Awaitable, Callable, Collection, Iterator, Sequence
20
+ from contextlib import contextmanager
21
+ from datetime import UTC, datetime
22
+ from typing import Final
23
+ from uuid import UUID
24
+
25
+ from agentic_runner.cli_floor import CLI_NAMES, meets_floor, parse_cli_version
26
+ from agentic_runner.workers._runtime_support import SubprocessResult, run_subprocess_exec
27
+ from agentic_runner.workers.contract_isolation import ContractIsolationError, DirectiveSandbox
28
+ from agentic_runner_contracts.runner_registration import HarnessSelfTest, SelfTestFailure
29
+
30
+ __all__ = [
31
+ "SELF_TEST_INTERVAL_SECONDS",
32
+ "DirectivesInFlight",
33
+ "HarnessSelfTests",
34
+ ]
35
+
36
+ SELF_TEST_INTERVAL_SECONDS: Final = 15 * 60
37
+ _TIMEOUT_SECONDS: Final = 10
38
+ _OUTPUT_LIMIT_BYTES: Final = 4096
39
+
40
+ # Where each harness reads its config root from; the same variable its runtime sets.
41
+ _HARNESS_ROOT_ENV: Final = {"codex_cli": "CODEX_HOME", "claude_code": "CLAUDE_CONFIG_DIR"}
42
+
43
+ SandboxFor = Callable[[str, str], DirectiveSandbox | None]
44
+
45
+
46
+ class DirectivesInFlight:
47
+ """Which Contracts have a Directive running in this process right now."""
48
+
49
+ def __init__(self) -> None:
50
+ self._running: Counter[str] = Counter()
51
+
52
+ @contextmanager
53
+ def running(self, contract_id: str | None) -> Iterator[None]:
54
+ key = contract_id or ""
55
+ self._running[key] += 1
56
+ try:
57
+ yield
58
+ finally:
59
+ self._running[key] -= 1
60
+ if self._running[key] <= 0:
61
+ del self._running[key]
62
+
63
+ def busy(self, contract_id: str) -> bool:
64
+ return self._running[contract_id] > 0
65
+
66
+
67
+ class HarnessSelfTests:
68
+ """The latest self-test per ``(contract_id, cli_kind)``, and when the next one is due."""
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ sandbox_for: SandboxFor,
74
+ in_flight: DirectivesInFlight,
75
+ served: Collection[str],
76
+ run: Callable[..., Awaitable[SubprocessResult]] = run_subprocess_exec,
77
+ monotonic: Callable[[], float],
78
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
79
+ interval_seconds: float = SELF_TEST_INTERVAL_SECONDS,
80
+ ) -> None:
81
+ self._sandbox_for = sandbox_for
82
+ self._in_flight = in_flight
83
+ self._served = frozenset(served)
84
+ self._run = run
85
+ self._monotonic = monotonic
86
+ self._clock = clock
87
+ self._interval = interval_seconds
88
+ self._results: dict[tuple[str, str], HarnessSelfTest] = {}
89
+ self._last_run: dict[tuple[str, str], float] = {}
90
+
91
+ def result(self, contract_id: UUID | str, cli_kind: str) -> HarnessSelfTest | None:
92
+ return self._results.get((str(contract_id), cli_kind))
93
+
94
+ async def run_due(self, targets: Sequence[tuple[str, str]]) -> None:
95
+ for gone in (self._results.keys() | self._last_run.keys()) - set(targets):
96
+ self._forget(gone)
97
+ for contract_id, cli_kind in targets:
98
+ key = (contract_id, cli_kind)
99
+ last = self._last_run.get(key)
100
+ if last is not None and self._monotonic() - last < self._interval:
101
+ continue
102
+ # Checked before the spawn only: a Directive that starts during the (at most
103
+ # _TIMEOUT_SECONDS) spawn overlaps it. Accepted -- `--version` neither reads
104
+ # nor writes the harness root's state and holds one process slot briefly.
105
+ if self._in_flight.busy(contract_id):
106
+ # Not stamped: it runs on the first sweep after the Directive ends.
107
+ continue
108
+ result = await self._self_test(contract_id, cli_kind)
109
+ if result is None:
110
+ self._forget(key)
111
+ continue
112
+ self._results[key] = result
113
+ self._last_run[key] = self._monotonic()
114
+
115
+ def _forget(self, key: tuple[str, str]) -> None:
116
+ self._results.pop(key, None)
117
+ self._last_run.pop(key, None)
118
+
119
+ async def _self_test(self, contract_id: str, cli_kind: str) -> HarnessSelfTest | None:
120
+ """``None`` when the harness root is gone, wiped since the sweep listed it."""
121
+
122
+ name = CLI_NAMES.get(cli_kind)
123
+ if name is None or cli_kind not in self._served:
124
+ return self._failed(SelfTestFailure.NOT_FOUND)
125
+ try:
126
+ sandbox = self._sandbox_for(contract_id, cli_kind)
127
+ except (ContractIsolationError, OSError):
128
+ return self._failed(SelfTestFailure.SPAWN_FAILED)
129
+ if sandbox is None:
130
+ return None
131
+ env = {
132
+ "PATH": os.environ.get("PATH") or os.defpath,
133
+ "HOME": str(sandbox.home_dir),
134
+ "TMPDIR": str(sandbox.tmp_dir),
135
+ _HARNESS_ROOT_ENV[cli_kind]: str(sandbox.harness_config_dir),
136
+ }
137
+ try:
138
+ answer = await self._run(
139
+ argv=[name, "--version"],
140
+ cwd=sandbox.home_dir,
141
+ env=env,
142
+ stdin=None,
143
+ timeout_seconds=_TIMEOUT_SECONDS,
144
+ output_limit_bytes=_OUTPUT_LIMIT_BYTES,
145
+ sandbox=sandbox,
146
+ )
147
+ except TimeoutError:
148
+ return self._failed(SelfTestFailure.TIMEOUT)
149
+ except FileNotFoundError:
150
+ return self._failed(SelfTestFailure.NOT_FOUND)
151
+ except (OSError, subprocess.SubprocessError):
152
+ # SubprocessError: the sandbox floor's own preexec hook refused the spawn.
153
+ return self._failed(SelfTestFailure.SPAWN_FAILED)
154
+ version = parse_cli_version(answer.stdout or answer.stderr)
155
+ if answer.exit_code != 0 or version is None:
156
+ return self._failed(SelfTestFailure.SPAWN_FAILED)
157
+ if not meets_floor(name, version):
158
+ return self._failed(SelfTestFailure.BELOW_FLOOR)
159
+ return HarnessSelfTest(ok=True, at=self._clock())
160
+
161
+ def _failed(self, reason: SelfTestFailure) -> HarnessSelfTest:
162
+ return HarnessSelfTest(ok=False, at=self._clock(), reason=reason)
@@ -56,6 +56,7 @@ from agentic_runner.credentials import (
56
56
  HostCredentialStore,
57
57
  )
58
58
  from agentic_runner.device_login_activities import ContractDeviceLoginActivities
59
+ from agentic_runner.harness_self_test import DirectivesInFlight, HarnessSelfTests
59
60
  from agentic_runner.heartbeat_link import WAKE_DIVERGENCE, HeartbeatLink
60
61
  from agentic_runner.hooks import AttemptFacts, HookName, HookRunner
61
62
  from agentic_runner.integrations.git.workspace import LocalGitWorkspace
@@ -168,6 +169,8 @@ TEMPORAL_TLS_ENV = "AGENTIC_TEMPORAL_TLS"
168
169
  # glances at their inbox; anything tighter spends IMAP logins, not attention.
169
170
  SOURCE_POLL_SECONDS_ENV = "AGENTIC_RUNNER_SOURCE_POLL_SECONDS"
170
171
  DEFAULT_SOURCE_POLL_SECONDS = 60.0
172
+ # How often due harness self-tests are looked for; each is due every 15 minutes at most.
173
+ SELF_TEST_SWEEP_SECONDS = 60.0
171
174
  # The Recipient Key is normally *generated here*, once per installation, and kept in the
172
175
  # state directory (PRD issue 48, 22 A4). These two exist for an installer that mints it
173
176
  # outside this process and are read only when both are set; the Helm chart takes the
@@ -558,6 +561,11 @@ class ControlPlaneStream:
558
561
  messages: MessageStore | None = None
559
562
  # Console-v2 issue 29: the latest start of each Runner-hosted Tool Server.
560
563
  tool_servers: ToolServerHealthLog | None = None
564
+ # Local-agents 17: each runtime's descriptor, the harness roots on disk and the last
565
+ # self-test under each.
566
+ runtimes: Mapping[str, AgentRuntime] = field(default_factory=dict)
567
+ contracts: ContractIsolation | None = None
568
+ self_tests: HarnessSelfTests | None = None
561
569
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
562
570
  llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
563
571
  _delivered: set[tuple[str, str]] = field(default_factory=set)
@@ -589,21 +597,44 @@ class ControlPlaneStream:
589
597
  def _harnesses(self) -> list[HarnessVersion]:
590
598
  """Per Contract: the CLI's version and whether it runs on a delivered key (22 A8)
591
599
  or on the login its funder created in the harness root -- read off the slot, never
592
- off the root, which the Runner does not open."""
600
+ off the root, which the Runner does not open. A Contract with a harness root and no
601
+ slot is listed too, so a login-only Contract gets its descriptor (local-agents 17).
602
+ """
593
603
 
594
604
  if self.attestation is None:
595
605
  return []
596
606
  versions = {cli.cli_kind: cli.version for cli in self.attestation.clis}
607
+ slots = {
608
+ (str(slot.contract_id), slot.runtime_kind): slot.present
609
+ for slot in self.proxy.slots.statuses()
610
+ }
611
+ roots = self.contracts.harness_roots() if self.contracts is not None else []
597
612
  return [
598
- HarnessVersion(
599
- contract_id=slot.contract_id,
600
- cli_kind=slot.runtime_kind,
601
- version=versions.get(slot.runtime_kind, "unknown"),
602
- auth_mode="api_key" if slot.present else "login",
613
+ self._harness(
614
+ contract_id, cli_kind, versions, delivered=slots.get((contract_id, cli_kind), False)
603
615
  )
604
- for slot in self.proxy.slots.statuses()
616
+ for contract_id, cli_kind in sorted(set(slots) | set(roots))
605
617
  ]
606
618
 
619
+ def _harness(
620
+ self, contract_id: str, cli_kind: str, versions: Mapping[str, str], *, delivered: bool
621
+ ) -> HarnessVersion:
622
+ runtime = self.runtimes.get(cli_kind)
623
+ capabilities = runtime.capabilities() if runtime is not None else None
624
+ return HarnessVersion(
625
+ contract_id=UUID(contract_id),
626
+ cli_kind=cli_kind,
627
+ version=versions.get(cli_kind, "unknown"),
628
+ auth_mode="api_key" if delivered else "login",
629
+ auth_modes=(sorted(capabilities.auth_modes) if capabilities is not None else None),
630
+ permission_mode=capabilities.permission_mode if capabilities is not None else None,
631
+ self_test=(
632
+ self.self_tests.result(contract_id, cli_kind)
633
+ if self.self_tests is not None
634
+ else None
635
+ ),
636
+ )
637
+
607
638
  async def directive_token(self, directive_id: str) -> str:
608
639
  """One activity execution's control-plane token, by Directive id (PRD issue 63)."""
609
640
 
@@ -929,6 +960,17 @@ async def _serve_registered(
929
960
  load = _LoadInterceptor()
930
961
  proxy = LlmProxy(slots=SlotStore(), ceilings=CeilingStore(), outbox=UsageOutbox())
931
962
  credentials = build_credential_resolver(config, host_store=host_store)
963
+ isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
964
+ runtimes = build_agent_runtimes(settings, attestation.clis if attestation is not None else [])
965
+ in_flight = DirectivesInFlight()
966
+ self_tests = HarnessSelfTests(
967
+ sandbox_for=lambda contract_id, cli_kind: isolation.existing_sandbox(
968
+ contract_id, runtime_kind=cli_kind
969
+ ),
970
+ in_flight=in_flight,
971
+ served=runtimes,
972
+ monotonic=time.monotonic,
973
+ )
932
974
  stream = ControlPlaneStream(
933
975
  client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
934
976
  state=state,
@@ -953,6 +995,9 @@ async def _serve_registered(
953
995
  attestation=attestation,
954
996
  lifecycle=lifecycle,
955
997
  heartbeat_stamp=state_dir / HEARTBEAT_STAMP_NAME,
998
+ runtimes=runtimes,
999
+ contracts=isolation,
1000
+ self_tests=self_tests,
956
1001
  )
957
1002
  stream.sources = UserSourcePoller(
958
1003
  stream=SignedIntakeStream(stream.client, state),
@@ -1001,12 +1046,13 @@ async def _serve_registered(
1001
1046
  settings=settings,
1002
1047
  config=config,
1003
1048
  state=state,
1004
- can_change_uid=can_change_uid,
1049
+ isolation=isolation,
1050
+ agent_runtimes=runtimes,
1051
+ directives_in_flight=in_flight,
1005
1052
  hooks=hooks,
1006
1053
  proxy=proxy,
1007
1054
  credentials=credentials,
1008
1055
  link=link,
1009
- attestation=attestation,
1010
1056
  token_source=stream,
1011
1057
  messages=messages,
1012
1058
  tool_servers=tool_servers,
@@ -1024,13 +1070,16 @@ async def _serve_registered(
1024
1070
  _heartbeat_forever(link, stream, client, interval, stop=stop, revoked=revoked)
1025
1071
  )
1026
1072
  poll = asyncio.create_task(_poll_sources_forever(stream.sources, _source_poll_seconds()))
1073
+ self_test = asyncio.create_task(
1074
+ _self_test_forever(self_tests, isolation, SELF_TEST_SWEEP_SECONDS)
1075
+ )
1027
1076
  readiness.ready = True
1028
1077
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
1029
1078
  try:
1030
1079
  await run_worker_until_terminated(worker, stop=stop)
1031
1080
  finally:
1032
1081
  readiness.ready = False
1033
- for task in (beat, poll):
1082
+ for task in (beat, poll, self_test):
1034
1083
  task.cancel()
1035
1084
  with contextlib.suppress(asyncio.CancelledError):
1036
1085
  await task
@@ -1112,18 +1161,36 @@ async def _poll_sources_forever(poller: UserSourcePoller, interval: float) -> No
1112
1161
  await poller.poll()
1113
1162
 
1114
1163
 
1164
+ async def _self_test_forever(
1165
+ self_tests: HarnessSelfTests, isolation: ContractIsolation, interval: float
1166
+ ) -> None:
1167
+ """Each harness root's self-test at start, then whenever it falls due (local-agents 17).
1168
+
1169
+ Only roots already on disk: a Contract's uid is allocated by its first Directive,
1170
+ which records the allocation, and a self-test must not be the call that does it.
1171
+ """
1172
+
1173
+ while True:
1174
+ try:
1175
+ await self_tests.run_due(isolation.harness_roots())
1176
+ except Exception as error: # noqa: BLE001 - a lost sweep is retried on the next one
1177
+ _logger.warning("harness self-test sweep failed: %s", error)
1178
+ await asyncio.sleep(interval)
1179
+
1180
+
1115
1181
  def _activities(
1116
1182
  fastapi: RunnerFastApiClient,
1117
1183
  *,
1118
1184
  settings: WorkerSettings,
1119
1185
  config: RunnerConfig,
1120
1186
  state: RunnerState,
1121
- can_change_uid: bool,
1187
+ isolation: ContractIsolation,
1188
+ agent_runtimes: Mapping[str, AgentRuntime],
1189
+ directives_in_flight: DirectivesInFlight,
1122
1190
  hooks: HookRunner,
1123
1191
  proxy: LlmProxy,
1124
1192
  credentials: CredentialResolver,
1125
1193
  link: HeartbeatLink,
1126
- attestation: HostAttestation | None,
1127
1194
  token_source: DirectiveTokenSource | None = None,
1128
1195
  messages: MessageStore | None = None,
1129
1196
  signaller: TemporalWorkflowSignaller | None = None,
@@ -1136,7 +1203,6 @@ def _activities(
1136
1203
  api_base_url=os.getenv("GITHUB_API_BASE_URL", "https://api.github.com"),
1137
1204
  )
1138
1205
  static_git_token = os.getenv("AGENTIC_OS_GIT_TOKEN") or None
1139
- isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
1140
1206
  attempt_records = AttemptRecords(config.state_dir, workspace_root=settings.WORKSPACE_ROOT)
1141
1207
  # Only the dead go: a live record is a harness this process lost on restart, still
1142
1208
  # writing its Workspace, and the next attempt must keep being refused (RR-05).
@@ -1144,9 +1210,7 @@ def _activities(
1144
1210
  ralph = RunnerRalphActivities(
1145
1211
  fastapi,
1146
1212
  runtime_context_resolver=WorkerRuntimeContextResolver(fastapi),
1147
- agent_runtimes=build_agent_runtimes(
1148
- settings, attestation.clis if attestation is not None else []
1149
- ),
1213
+ agent_runtimes=agent_runtimes,
1150
1214
  git_workspace=LocalGitWorkspace(
1151
1215
  git_token=static_git_token,
1152
1216
  git_token_provider=(
@@ -1177,6 +1241,7 @@ def _activities(
1177
1241
  workflow_signaller=signaller,
1178
1242
  tool_server_health=tool_servers,
1179
1243
  attempt_records=attempt_records,
1244
+ directives_in_flight=directives_in_flight,
1180
1245
  )
1181
1246
  return [
1182
1247
  *ralph.activity_callables(),
@@ -101,6 +101,24 @@ class DirectiveResult:
101
101
  evidence: DirectiveEvidence
102
102
 
103
103
 
104
+ # The permission mode of a runtime whose guard configuration is incomplete: it refuses
105
+ # every Directive, so there is no mode it passes.
106
+ REFUSED_PERMISSION_MODE = "refused"
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class RuntimeCapabilities:
111
+ """What a runtime declares about itself on the heartbeat (local-agents 17).
112
+
113
+ ``permission_mode`` is the fixed permission and approval mode the runtime passes its
114
+ harness, as a short patterned string. It is reported, never the gate: the Runner's
115
+ own floor is (ADR-0011 §12).
116
+ """
117
+
118
+ auth_modes: frozenset[AuthMode]
119
+ permission_mode: str
120
+
121
+
104
122
  @runtime_checkable
105
123
  class AgentRuntime(Protocol):
106
124
  """The pluggable engine an Agent uses to execute one Directive per step."""
@@ -111,6 +129,8 @@ class AgentRuntime(Protocol):
111
129
  # counts as "an API key is present" when the Runner chooses the mode.
112
130
  host_api_key: bool
113
131
 
132
+ def capabilities(self) -> RuntimeCapabilities: ...
133
+
114
134
  async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult: ...
115
135
 
116
136