agentic-runner 3.2.0__tar.gz → 3.4.0__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.2.0 → agentic_runner-3.4.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/activities.py +224 -26
  4. agentic_runner-3.4.0/src/agentic_runner/auth_mode.py +86 -0
  5. agentic_runner-3.4.0/src/agentic_runner/cli_floor.py +33 -0
  6. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/config.py +5 -0
  7. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/device_login_activities.py +24 -0
  8. agentic_runner-3.4.0/src/agentic_runner/harness_self_test.py +162 -0
  9. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/llm_proxy.py +101 -17
  10. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/sealed_box.py +24 -0
  11. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/service.py +143 -19
  12. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__main__.py +12 -1
  13. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/control_plane.py +174 -3
  14. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/_runtime_support.py +6 -1
  15. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/agent_runtime.py +36 -5
  16. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/claude_runtime.py +17 -2
  17. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/codex_runtime.py +77 -4
  18. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/command_policy.py +9 -1
  19. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_device_login.py +15 -0
  20. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/contract_isolation.py +32 -0
  21. agentic_runner-3.4.0/src/agentic_runner/workers/harness_outcome.py +213 -0
  22. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workstation.py +4 -20
  23. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/.gitignore +0 -0
  24. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/LICENSE +0 -0
  25. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/README.md +0 -0
  26. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/pyproject.toml +0 -0
  27. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/attempts.py +0 -0
  28. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/build.py +0 -0
  29. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/callback.py +0 -0
  30. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/child_watcher.py +0 -0
  31. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/cli.py +0 -0
  32. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/credentials.py +0 -0
  33. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/egress.py +0 -0
  34. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/heartbeat_link.py +0 -0
  35. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/hooks.py +0 -0
  36. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/host_store.py +0 -0
  37. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/__init__.py +0 -0
  38. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  39. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  40. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  41. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  42. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  43. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  44. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  45. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  46. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  47. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/lifecycle.py +0 -0
  48. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/mcp.py +0 -0
  49. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/message_store.py +0 -0
  50. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/private_state.py +0 -0
  51. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/py.typed +0 -0
  52. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  53. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/registration.py +0 -0
  54. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/__init__.py +0 -0
  55. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  56. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/__init__.py +0 -0
  57. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/plugin.py +0 -0
  58. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  59. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/testing/workflow.py +0 -0
  60. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/tiny_http.py +0 -0
  61. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/triage_activities.py +0 -0
  62. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/user_sources.py +0 -0
  63. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/__init__.py +0 -0
  64. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  65. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  66. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  67. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/src/agentic_runner/workers/settings.py +0 -0
  68. {agentic_runner-3.2.0 → agentic_runner-3.4.0}/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.2.0
3
+ Version: 3.4.0
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.2.0"
10
+ __version__ = "3.4.0"
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
@@ -44,6 +45,11 @@ from agentic_runner.attempts import (
44
45
  fence_work_record,
45
46
  refuse_if_prior_attempt_alive,
46
47
  )
48
+ from agentic_runner.auth_mode import (
49
+ AuthModeRefusedError,
50
+ choose_auth_mode,
51
+ is_shared_runner,
52
+ )
47
53
  from agentic_runner.callback import (
48
54
  CALLBACK_TOKEN_ENV,
49
55
  AnnotateRequest,
@@ -70,6 +76,7 @@ from agentic_runner.credentials import (
70
76
  UnresolvableCredentialReferenceError,
71
77
  )
72
78
  from agentic_runner.egress import EGRESS_REFUSED_SOURCE, EgressProxy, Resolver, host_of
79
+ from agentic_runner.harness_self_test import DirectivesInFlight
73
80
  from agentic_runner.heartbeat_link import HeartbeatLink, SeamUnavailableError
74
81
  from agentic_runner.hooks import (
75
82
  HOOK_EVIDENCE_SOURCE,
@@ -118,11 +125,12 @@ from agentic_runner.workers._runtime_support import (
118
125
  )
119
126
  from agentic_runner.workers.agent_runtime import (
120
127
  AgentRuntime,
121
- AuthModel,
128
+ AuthMode,
122
129
  DirectiveRequest,
123
130
  DirectiveResult,
124
131
  ResumableAgentRuntime,
125
132
  )
133
+ from agentic_runner.workers.contract_device_login import login_file_counts
126
134
  from agentic_runner.workers.contract_isolation import (
127
135
  NO_CONTRACT,
128
136
  ContractIsolation,
@@ -134,6 +142,7 @@ from agentic_runner.workers.fastapi_client import (
134
142
  WorkerFastApiClientError,
135
143
  directive_token,
136
144
  )
145
+ from agentic_runner.workers.harness_outcome import classify_harness_outcome
137
146
  from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
138
147
  from agentic_runner.workers.mcp_config import McpServerEntry
139
148
  from agentic_runner.workers.skills import (
@@ -162,6 +171,7 @@ from agentic_runner_contracts.activity_io import (
162
171
  FixDirectiveOutput,
163
172
  GitHubCallError,
164
173
  GitHubCallFailure,
174
+ HarnessHold,
165
175
  LearningDirectiveInput,
166
176
  LearningDirectiveOutput,
167
177
  MemberDirectiveInput,
@@ -638,6 +648,9 @@ class _DirectiveAttempt:
638
648
  # Console-v2 issue 28: the attached Skills, for a runtime with no skills directory to
639
649
  # write them into. Prefixed to the Directive prompt; empty when there is none.
640
650
  skills_preamble: str = ""
651
+ # Chosen before anything is spawned (local-agents 04); the runtime runs in it and
652
+ # the harness-usage report follows it.
653
+ auth_mode: AuthMode = AuthMode.API_KEY
641
654
 
642
655
  async def run_environment_hook(self) -> None:
643
656
  self.extra_env.update(await self.hooks.environment())
@@ -1432,6 +1445,8 @@ HARNESS_SESSION_EVIDENCE_SOURCE = "runner.harness_session"
1432
1445
  DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
1433
1446
  AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
1434
1447
  SKILLS_EVIDENCE_SOURCE = "runner.skills"
1448
+ HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
1449
+ HARNESS_HOLD_SOURCE = "runner.harness_hold"
1435
1450
 
1436
1451
 
1437
1452
  class _EvidenceWriter(Protocol):
@@ -1545,8 +1560,11 @@ class RunnerRalphActivities:
1545
1560
  egress_resolver: Resolver | None = None,
1546
1561
  tool_server_health: ToolServerHealthLog | None = None,
1547
1562
  attempt_records: AttemptRecords | None = None,
1563
+ directives_in_flight: DirectivesInFlight | None = None,
1548
1564
  ) -> None:
1549
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()
1550
1568
  # Wall-clock source for per-Directive runtime, accrued against the Budget's
1551
1569
  # wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
1552
1570
  # so a real monotonic clock is allowed here (it must never be used in the
@@ -1578,6 +1596,10 @@ class RunnerRalphActivities:
1578
1596
  # no proxy here keeps calling the backend one, which is exactly the transition
1579
1597
  # the issue describes.
1580
1598
  self._llm_proxy = llm_proxy
1599
+ # Contracts whose harness roots this process has already checked for a login
1600
+ # left behind (local-agents 04). Only de-duplicates an Evidence line -- no later
1601
+ # activity reads it -- so a restart reporting the residue again is the intent.
1602
+ self._residue_checked: set[str | None] = set()
1581
1603
  # Credential References (PRD issue 48, 22 A1). None means this Runner resolves
1582
1604
  # none: a Contract that declares a manifest then fails its Directives closed,
1583
1605
  # which is the right answer -- a Runner with no host store cannot host that
@@ -1969,6 +1991,7 @@ class RunnerRalphActivities:
1969
1991
  extra_env=attempt.env,
1970
1992
  mcp_servers=attempt.mcp_servers,
1971
1993
  egress_allow_list=attempt.egress_allow_list,
1994
+ auth_mode=attempt.auth_mode,
1972
1995
  prompt=attempt.skills_preamble
1973
1996
  + _directive_prompt(
1974
1997
  completion_criteria=runtime_state.completion_criteria,
@@ -2000,8 +2023,34 @@ class RunnerRalphActivities:
2000
2023
  work_record_id=request.work_record_id,
2001
2024
  directive_number=request.directive_number,
2002
2025
  result=codex_result,
2026
+ auth_mode=attempt.auth_mode,
2003
2027
  )
2004
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
+ )
2005
2054
  if codex_result.exit_code != 0:
2006
2055
  if _is_guard_mode_refusal(codex_result):
2007
2056
  # The runtime refused this Directive (guard-mode). Return a flagged
@@ -2419,6 +2468,8 @@ class RunnerRalphActivities:
2419
2468
  the bearer defensible under ADR-0011 §9.
2420
2469
  """
2421
2470
 
2471
+ auth_mode = await self._directive_auth_mode(work_record_id, state)
2472
+ await self._report_login_residue(work_record_id, state)
2422
2473
  facts = AttemptFacts(
2423
2474
  work_record_id=work_record_id,
2424
2475
  directive_id=directive_id(
@@ -2457,6 +2508,7 @@ class RunnerRalphActivities:
2457
2508
  scratch_root = sandbox.tmp_dir if sandbox is not None else Path(tempfile.gettempdir())
2458
2509
  attempt_dir = prepare_attempt_dir(scratch_root / f"attempt-{key}", uid)
2459
2510
  async with contextlib.AsyncExitStack() as stack:
2511
+ stack.enter_context(self._directives_in_flight.running(facts.contract_id))
2460
2512
  stack.callback(shutil.rmtree, attempt_dir, ignore_errors=True)
2461
2513
  callback_env: dict[str, str] = {}
2462
2514
  if self._socket_dir is not None:
@@ -2476,8 +2528,8 @@ class RunnerRalphActivities:
2476
2528
  )
2477
2529
  )
2478
2530
  callback_env = server.env()
2479
- if self._llm_proxy is not None and self._meters_through_the_proxy(state):
2480
- # API-key mode only (issue 43): a device-login Contract never has a slot
2531
+ if self._llm_proxy is not None and auth_mode == AuthMode.API_KEY:
2532
+ # API-key mode only (issue 43): a subscription Directive never has a slot
2481
2533
  # to resolve, and issue 31 meters it from the harness's own output. One
2482
2534
  # bearer serves the callback socket and the proxy alike.
2483
2535
  proxy_attempt = await stack.enter_async_context(
@@ -2528,6 +2580,7 @@ class RunnerRalphActivities:
2528
2580
  mcp_servers=mcp_servers,
2529
2581
  egress_allow_list=egress_allow_list,
2530
2582
  skills_preamble=delivered_preamble,
2583
+ auth_mode=auth_mode,
2531
2584
  )
2532
2585
 
2533
2586
  async def _refuse_skill_digest_mismatch(
@@ -2805,26 +2858,86 @@ class RunnerRalphActivities:
2805
2858
  )
2806
2859
  raise
2807
2860
 
2808
- def _meters_through_the_proxy(self, state: _RuntimeContextState) -> bool:
2809
- """Whether this Directive's runtime is metered at the proxy (issue 43).
2810
-
2811
- The mirror of :meth:`_report_harness_usage`'s test: an ``api_key`` Directive
2812
- routes through the proxy and is metered there; a device-login one bypasses it
2813
- entirely and is metered from the harness's own output by PRD issue 31. Read off
2814
- the runtime the Work Record's `cli_kind` names, never a process-wide one.
2861
+ async def _directive_auth_mode(
2862
+ self, work_record_id: str, state: _RuntimeContextState
2863
+ ) -> AuthMode:
2864
+ """This Directive's auth mode, or a non-retryable refusal with Evidence naming the
2865
+ rule (local-agents 04).
2866
+
2867
+ Decided from the host party this process was registered with and from whether a
2868
+ key is present for the Contract -- never from the payload. A runtime that declares
2869
+ no modes (a test double built before the port grew the field) reads as
2870
+ ``api_key``-only, and a kind this Runner does not serve reads the same way; that
2871
+ Directive fails closed in :meth:`_agent_runtime_for` regardless.
2815
2872
  """
2816
2873
 
2817
- return self._auth_model(state) == AuthModel.API_KEY
2818
-
2819
- def _auth_model(self, state: _RuntimeContextState) -> AuthModel:
2820
- # `getattr` with a default, not `.auth_model` outright: a runtime that carries
2821
- # none (a test double built before this port grew the field) is the same case as
2822
- # `api_key` -- nothing to report from the harness -- never a crash on an
2823
- # unrelated Directive. A kind this Runner does not serve reads the same way; the
2824
- # Directive itself has already failed closed in :meth:`_agent_runtime_for`.
2825
2874
  runtime = self._agent_runtimes.get(state.cli_kind)
2826
- model = getattr(runtime, "auth_model", AuthModel.API_KEY)
2827
- return model if isinstance(model, AuthModel) else AuthModel.API_KEY
2875
+ host_party = self._routing_identity.host_party if self._routing_identity else None
2876
+ try:
2877
+ return choose_auth_mode(
2878
+ host_party=host_party,
2879
+ key_present=self._api_key_present(state, runtime),
2880
+ runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
2881
+ )
2882
+ except AuthModeRefusedError as refusal:
2883
+ await _raise_recorded(
2884
+ ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
2885
+ self._fastapi_client,
2886
+ work_record_id,
2887
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
2888
+ actor=self._actor(),
2889
+ payload={
2890
+ "event": "directive.auth_mode_refused",
2891
+ "rule": refusal.rule,
2892
+ "cli_kind": state.cli_kind,
2893
+ "host_party": host_party,
2894
+ "contract_id": state.contract_id,
2895
+ },
2896
+ )
2897
+
2898
+ def _api_key_present(self, state: _RuntimeContextState, runtime: AgentRuntime | None) -> bool:
2899
+ """A delivered slot for the Contract (or its funder), or the host operator's key."""
2900
+
2901
+ if bool(getattr(runtime, "host_api_key", False)):
2902
+ return True
2903
+ contract_id = _optional_uuid(state.contract_id)
2904
+ return (
2905
+ self._llm_proxy is not None
2906
+ and contract_id is not None
2907
+ and self._llm_proxy.slots.holds(contract_id)
2908
+ )
2909
+
2910
+ async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
2911
+ """On a shared Runner, say once that a Contract's harness root holds a login.
2912
+
2913
+ A count per harness, never a path or a byte of the file: the Runner does not open
2914
+ it, a shared Runner never runs on it, and ``wipe_contract_residue`` removes it with
2915
+ the Contract. Saying so is what turns a sign-in made before local-agents 03 (or by
2916
+ hand) into something the Organisation can see and act on.
2917
+ """
2918
+
2919
+ host_party = self._routing_identity.host_party if self._routing_identity else None
2920
+ if (
2921
+ self._contract_isolation is None
2922
+ or not is_shared_runner(host_party)
2923
+ or state.contract_id in self._residue_checked
2924
+ ):
2925
+ return
2926
+ self._residue_checked.add(state.contract_id)
2927
+ counts = login_file_counts(self._contract_isolation, state.contract_id)
2928
+ if not counts:
2929
+ return
2930
+ await self._fastapi_client.append_evidence(
2931
+ work_record_id,
2932
+ source=HARNESS_LOGIN_RESIDUE_SOURCE,
2933
+ actor=self._actor(),
2934
+ payload={
2935
+ "event": "harness.login_residue",
2936
+ "contract_id": state.contract_id,
2937
+ "host_party": host_party,
2938
+ "login_files": counts,
2939
+ },
2940
+ )
2828
2941
 
2829
2942
  async def _agent_runtime_for(
2830
2943
  self, work_record_id: str, state: _RuntimeContextState
@@ -4405,7 +4518,13 @@ class RunnerRalphActivities:
4405
4518
  guard_mode_refused=True,
4406
4519
  workspace_path=request.workspace_path,
4407
4520
  )
4408
- 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
+ ):
4409
4528
  self._message_store.mark_consumed(
4410
4529
  contract_id=contract[0],
4411
4530
  work_record_id=request.work_record_id,
@@ -4516,6 +4635,7 @@ class RunnerRalphActivities:
4516
4635
  extra_env=attempt.env,
4517
4636
  mcp_servers=attempt.mcp_servers,
4518
4637
  egress_allow_list=attempt.egress_allow_list,
4638
+ auth_mode=attempt.auth_mode,
4519
4639
  prompt=attempt.skills_preamble
4520
4640
  + _learning_prompt(
4521
4641
  request,
@@ -4534,6 +4654,14 @@ class RunnerRalphActivities:
4534
4654
  work_record_id=request.work_record_id,
4535
4655
  directive_number=request.directive_number,
4536
4656
  result=result,
4657
+ auth_mode=attempt.auth_mode,
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,
4537
4665
  )
4538
4666
  usage = await self._directive_usage(
4539
4667
  work_record_id=request.work_record_id,
@@ -4571,6 +4699,7 @@ class RunnerRalphActivities:
4571
4699
  usage=usage,
4572
4700
  outcome=LEARNING_FAILED,
4573
4701
  summary=f"the Learner's runtime exited {result.exit_code}",
4702
+ harness_hold=harness_hold,
4574
4703
  )
4575
4704
  return self._learning_output(
4576
4705
  request,
@@ -4587,6 +4716,7 @@ class RunnerRalphActivities:
4587
4716
  usage: DirectiveUsage | None = None,
4588
4717
  outcome: str = LEARNING_PROPOSED,
4589
4718
  summary: str = "",
4719
+ harness_hold: HarnessHold | None = None,
4590
4720
  ) -> LearningDirectiveOutput:
4591
4721
  return LearningDirectiveOutput(
4592
4722
  work_record_id=request.work_record_id,
@@ -4595,6 +4725,7 @@ class RunnerRalphActivities:
4595
4725
  usage=usage or DirectiveUsage(),
4596
4726
  outcome=outcome,
4597
4727
  summary=summary,
4728
+ harness_hold=harness_hold,
4598
4729
  )
4599
4730
 
4600
4731
  def _learner_transcript(self, state: _RuntimeContextState, work_record_id: str) -> str:
@@ -4764,6 +4895,7 @@ class RunnerRalphActivities:
4764
4895
  extra_env=attempt.env,
4765
4896
  mcp_servers=attempt.mcp_servers,
4766
4897
  egress_allow_list=attempt.egress_allow_list,
4898
+ auth_mode=attempt.auth_mode,
4767
4899
  prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
4768
4900
  base_branch=request.base_ref,
4769
4901
  work_branch=state.work_branch,
@@ -4789,9 +4921,34 @@ class RunnerRalphActivities:
4789
4921
  work_record_id=request.work_record_id,
4790
4922
  directive_number=request.directive_number,
4791
4923
  result=codex_result,
4924
+ auth_mode=attempt.auth_mode,
4792
4925
  )
4793
4926
  # Taken before the commit below so it never lands on the branch (PRD 59).
4794
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
+ )
4795
4952
  if codex_result.exit_code != 0:
4796
4953
  if _is_guard_mode_refusal(codex_result):
4797
4954
  return FixDirectiveOutput(
@@ -4987,14 +5144,15 @@ class RunnerRalphActivities:
4987
5144
  work_record_id: str,
4988
5145
  directive_number: int,
4989
5146
  result: DirectiveResult,
5147
+ auth_mode: AuthMode,
4990
5148
  ) -> None:
4991
- """A device-login/setup-token Directive bypasses the LLM proxy, so this is the
4992
- only metering the Runner can produce for it (PRD issue 31, 17 A9). A no-op for an
4993
- ``api_key`` runtime -- the proxy already metered that call at request time -- and
4994
- never the reason a Directive fails (same posture as :meth:`_directive_usage`).
5149
+ """A subscription Directive bypasses the LLM proxy, so this is the only metering
5150
+ the Runner can produce for it (PRD issue 31, 17 A9). A no-op for an ``api_key``
5151
+ Directive -- the proxy already metered that call at request time -- and never the
5152
+ reason a Directive fails (same posture as :meth:`_directive_usage`).
4995
5153
  """
4996
5154
 
4997
- if self._auth_model(runtime_state) == AuthModel.API_KEY:
5155
+ if auth_mode == AuthMode.API_KEY:
4998
5156
  return
4999
5157
  directive = directive_id(work_record_id=work_record_id, directive_number=directive_number)
5000
5158
  if runtime_state.cli_kind == "claude_code":
@@ -5030,6 +5188,46 @@ class RunnerRalphActivities:
5030
5188
  }
5031
5189
  )
5032
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
+
5033
5231
  @activity.defn(name="wipe_contract_residue")
5034
5232
  async def wipe_contract_residue(self, request: ContractResidueInput) -> ContractResidueOutput:
5035
5233
  """Delete everything one Contract left on this Runner and retire its uid (17 A6).
@@ -0,0 +1,86 @@
1
+ """Which auth mode one Directive runs in, decided on the Runner before spawn (local-agents 04).
2
+
3
+ Subscriptions follow the person, not the host (owner, 2026-09-26): a harness runs on a
4
+ subscription login only on the Contract person's own Runner -- host party ``user``, which
5
+ routing has already tied to the Contract's person (``assert_routed`` here, the platform's
6
+ ``_subscription_bound`` there) -- whatever channel the Runner was installed by. A shared
7
+ Runner (``organisation`` or ``account``) runs every Directive through the LLM proxy on an
8
+ API key and refuses sign-in. The platform refuses an ineligible sign-in at the door
9
+ (local-agents 03); this module is the backstop, so it decides from what the Runner itself
10
+ knows and never from the payload.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Collection
16
+ from typing import Final
17
+
18
+ from agentic_runner.workers.agent_runtime import AuthMode
19
+
20
+ __all__ = [
21
+ "SETUP_TOKEN_REFUSED_RULE",
22
+ "SHARED_HOST_PARTIES",
23
+ "SHARED_RUNNER_SIGN_IN_RULE",
24
+ "SHARED_RUNNER_SUBSCRIPTION_RULE",
25
+ "AuthModeRefusedError",
26
+ "choose_auth_mode",
27
+ "is_setup_token",
28
+ "is_shared_runner",
29
+ ]
30
+
31
+ SHARED_HOST_PARTIES: Final[frozenset[str]] = frozenset({"organisation", "account"})
32
+
33
+ # The rule names a refusal's Evidence carries, so a support read can say which line held.
34
+ SHARED_RUNNER_SUBSCRIPTION_RULE: Final[str] = "shared_runner_api_key_only"
35
+ SHARED_RUNNER_SIGN_IN_RULE: Final[str] = "shared_runner_sign_in_refused"
36
+ SETUP_TOKEN_REFUSED_RULE: Final[str] = "setup_token_refused"
37
+
38
+ # `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
39
+ # `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
40
+ # Runner can refuse on.
41
+ _SETUP_TOKEN_PREFIX: Final[str] = "sk-ant-oat"
42
+
43
+
44
+ class AuthModeRefusedError(RuntimeError):
45
+ """A Directive or sign-in this Runner will not run; ``rule`` is the stable code."""
46
+
47
+ def __init__(self, rule: str, message: str) -> None:
48
+ super().__init__(message)
49
+ self.rule = rule
50
+
51
+
52
+ def is_shared_runner(host_party: str | None) -> bool:
53
+ return host_party in SHARED_HOST_PARTIES
54
+
55
+
56
+ def choose_auth_mode(
57
+ *,
58
+ host_party: str | None,
59
+ key_present: bool,
60
+ runtime_modes: Collection[AuthMode],
61
+ ) -> AuthMode:
62
+ """``api_key`` whenever a key is present; otherwise ``subscription`` on a user-hosted
63
+ Runner whose runtime can run one, and a refusal on a shared Runner.
64
+
65
+ The refusal is the shared Runner's "subscription case": with no key there is nothing
66
+ for the proxy to spend, and the only thing left that could authenticate the harness is
67
+ a login, which a shared Runner never uses. An unregistered process (``host_party``
68
+ ``None``) is not user-hosted either, so it never runs a subscription; it runs on a key
69
+ or fails at the proxy for want of one, as it would today.
70
+ """
71
+
72
+ if key_present:
73
+ return AuthMode.API_KEY
74
+ if is_shared_runner(host_party):
75
+ raise AuthModeRefusedError(
76
+ SHARED_RUNNER_SUBSCRIPTION_RULE,
77
+ f"this Runner is hosted by {host_party!r}, so it runs API keys only, and no API "
78
+ "key is present for this Contract",
79
+ )
80
+ if host_party == "user" and AuthMode.SUBSCRIPTION in runtime_modes:
81
+ return AuthMode.SUBSCRIPTION
82
+ return AuthMode.API_KEY
83
+
84
+
85
+ def is_setup_token(value: str) -> bool:
86
+ return value.strip().startswith(_SETUP_TOKEN_PREFIX)
@@ -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])
@@ -51,6 +51,11 @@ class RunnerConfig(BaseModel):
51
51
  # fails its Directives with Evidence naming the reference (22 A1), instead of running
52
52
  # without the credential it was told to use.
53
53
  credential_store: Path | None = None
54
+ # Where a delivered OpenAI / Anthropic key is spent (local-agents 04b), when not at
55
+ # the vendor itself: a gateway the host runs in front of it. The host already holds
56
+ # the opened value, so pointing it elsewhere grants the host nothing it lacked.
57
+ openai_base_url: str | None = None
58
+ anthropic_base_url: str | None = None
54
59
  log_level: str = "info"
55
60
 
56
61
 
@@ -11,7 +11,9 @@ from collections.abc import Callable
11
11
  from datetime import UTC, datetime
12
12
 
13
13
  from temporalio import activity
14
+ from temporalio.exceptions import ApplicationError
14
15
 
16
+ from agentic_runner.auth_mode import SHARED_RUNNER_SIGN_IN_RULE
15
17
  from agentic_runner.workers.contract_device_login import ContractDeviceLogin
16
18
  from agentic_runner.workers.contract_isolation import ContractIsolation
17
19
  from agentic_runner_contracts.activity_io import (
@@ -31,8 +33,12 @@ class ContractDeviceLoginActivities:
31
33
  *,
32
34
  contract_isolation: ContractIsolation,
33
35
  device_login: ContractDeviceLogin | None = None,
36
+ host_party: str | None = None,
34
37
  ) -> None:
35
38
  self._contract_isolation = contract_isolation
39
+ # The party this process was registered as hosted by. A shared Runner never runs a
40
+ # subscription, so it never starts or reads one either (local-agents 04).
41
+ self._host_party = host_party
36
42
  # Injectable for tests; None falls through to the real `codex` subprocess.
37
43
  self._device_login = device_login or ContractDeviceLogin(contract_isolation)
38
44
 
@@ -46,6 +52,7 @@ class ContractDeviceLoginActivities:
46
52
  async def sign_in_contract_device_login(
47
53
  self, request: ContractDeviceLoginInput
48
54
  ) -> ContractDeviceLoginResult:
55
+ self._refuse_on_shared_runner()
49
56
  prompt = await self._device_login.sign_in(
50
57
  request.contract_id, runtime_kind=request.runtime_kind
51
58
  )
@@ -61,6 +68,7 @@ class ContractDeviceLoginActivities:
61
68
  async def check_contract_device_login_status(
62
69
  self, request: ContractDeviceLoginStatusInput
63
70
  ) -> ContractDeviceLoginStatusResult:
71
+ self._refuse_on_shared_runner()
64
72
  present = self._device_login.token_present(
65
73
  request.contract_id, runtime_kind=request.runtime_kind
66
74
  )
@@ -77,3 +85,19 @@ class ContractDeviceLoginActivities:
77
85
  token_present=present,
78
86
  delivered_at=delivered_at,
79
87
  )
88
+
89
+ def _refuse_on_shared_runner(self) -> None:
90
+ """Non-retryable, and typed by the rule: there is no Work Record to write Evidence
91
+ on here, so the failure the platform's sign-in workflow receives names it.
92
+
93
+ Only a Runner known to be the person's own signs in: one whose state names no host
94
+ party (registered before issue 42) is refused too, as `choose_auth_mode` never runs
95
+ a subscription on it either."""
96
+
97
+ if self._host_party != "user":
98
+ raise ApplicationError(
99
+ f"this Runner is hosted by {self._host_party!r}; only a person's own Runner "
100
+ "signs in, a shared Runner runs API keys only",
101
+ type=SHARED_RUNNER_SIGN_IN_RULE,
102
+ non_retryable=True,
103
+ )