agentic-runner 3.2.0__tar.gz → 3.3.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 (65) hide show
  1. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/activities.py +109 -25
  4. agentic_runner-3.3.0/src/agentic_runner/auth_mode.py +86 -0
  5. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/config.py +5 -0
  6. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/device_login_activities.py +24 -0
  7. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/llm_proxy.py +101 -17
  8. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/sealed_box.py +24 -0
  9. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/service.py +62 -3
  10. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/__main__.py +12 -1
  11. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/control_plane.py +174 -3
  12. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/_runtime_support.py +6 -1
  13. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/agent_runtime.py +16 -5
  14. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/claude_runtime.py +5 -2
  15. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/codex_runtime.py +61 -4
  16. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/command_policy.py +9 -1
  17. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/contract_device_login.py +15 -0
  18. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/.gitignore +0 -0
  19. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/LICENSE +0 -0
  20. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/README.md +0 -0
  21. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/pyproject.toml +0 -0
  22. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/attempts.py +0 -0
  23. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/build.py +0 -0
  24. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/callback.py +0 -0
  25. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/child_watcher.py +0 -0
  26. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/cli.py +0 -0
  27. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/credentials.py +0 -0
  28. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/egress.py +0 -0
  29. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/heartbeat_link.py +0 -0
  30. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/hooks.py +0 -0
  31. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/host_store.py +0 -0
  32. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/__init__.py +0 -0
  33. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  34. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  35. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  36. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  37. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  38. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  39. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  40. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  41. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  42. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/lifecycle.py +0 -0
  43. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/mcp.py +0 -0
  44. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/message_store.py +0 -0
  45. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/private_state.py +0 -0
  46. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/py.typed +0 -0
  47. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  48. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/registration.py +0 -0
  49. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/runtime/__init__.py +0 -0
  50. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  51. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/__init__.py +0 -0
  52. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/plugin.py +0 -0
  53. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  54. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/testing/workflow.py +0 -0
  55. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/tiny_http.py +0 -0
  56. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/triage_activities.py +0 -0
  57. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/user_sources.py +0 -0
  58. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/__init__.py +0 -0
  59. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  60. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  61. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  62. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  63. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/settings.py +0 -0
  64. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/src/agentic_runner/workers/skills.py +0 -0
  65. {agentic_runner-3.2.0 → agentic_runner-3.3.0}/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.2.0
3
+ Version: 3.3.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.3.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -44,6 +44,11 @@ from agentic_runner.attempts import (
44
44
  fence_work_record,
45
45
  refuse_if_prior_attempt_alive,
46
46
  )
47
+ from agentic_runner.auth_mode import (
48
+ AuthModeRefusedError,
49
+ choose_auth_mode,
50
+ is_shared_runner,
51
+ )
47
52
  from agentic_runner.callback import (
48
53
  CALLBACK_TOKEN_ENV,
49
54
  AnnotateRequest,
@@ -118,11 +123,12 @@ from agentic_runner.workers._runtime_support import (
118
123
  )
119
124
  from agentic_runner.workers.agent_runtime import (
120
125
  AgentRuntime,
121
- AuthModel,
126
+ AuthMode,
122
127
  DirectiveRequest,
123
128
  DirectiveResult,
124
129
  ResumableAgentRuntime,
125
130
  )
131
+ from agentic_runner.workers.contract_device_login import login_file_counts
126
132
  from agentic_runner.workers.contract_isolation import (
127
133
  NO_CONTRACT,
128
134
  ContractIsolation,
@@ -638,6 +644,9 @@ class _DirectiveAttempt:
638
644
  # Console-v2 issue 28: the attached Skills, for a runtime with no skills directory to
639
645
  # write them into. Prefixed to the Directive prompt; empty when there is none.
640
646
  skills_preamble: str = ""
647
+ # Chosen before anything is spawned (local-agents 04); the runtime runs in it and
648
+ # the harness-usage report follows it.
649
+ auth_mode: AuthMode = AuthMode.API_KEY
641
650
 
642
651
  async def run_environment_hook(self) -> None:
643
652
  self.extra_env.update(await self.hooks.environment())
@@ -1432,6 +1441,7 @@ HARNESS_SESSION_EVIDENCE_SOURCE = "runner.harness_session"
1432
1441
  DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
1433
1442
  AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
1434
1443
  SKILLS_EVIDENCE_SOURCE = "runner.skills"
1444
+ HARNESS_LOGIN_RESIDUE_SOURCE = "runner.harness_login_residue"
1435
1445
 
1436
1446
 
1437
1447
  class _EvidenceWriter(Protocol):
@@ -1578,6 +1588,10 @@ class RunnerRalphActivities:
1578
1588
  # no proxy here keeps calling the backend one, which is exactly the transition
1579
1589
  # the issue describes.
1580
1590
  self._llm_proxy = llm_proxy
1591
+ # Contracts whose harness roots this process has already checked for a login
1592
+ # left behind (local-agents 04). Only de-duplicates an Evidence line -- no later
1593
+ # activity reads it -- so a restart reporting the residue again is the intent.
1594
+ self._residue_checked: set[str | None] = set()
1581
1595
  # Credential References (PRD issue 48, 22 A1). None means this Runner resolves
1582
1596
  # none: a Contract that declares a manifest then fails its Directives closed,
1583
1597
  # which is the right answer -- a Runner with no host store cannot host that
@@ -1969,6 +1983,7 @@ class RunnerRalphActivities:
1969
1983
  extra_env=attempt.env,
1970
1984
  mcp_servers=attempt.mcp_servers,
1971
1985
  egress_allow_list=attempt.egress_allow_list,
1986
+ auth_mode=attempt.auth_mode,
1972
1987
  prompt=attempt.skills_preamble
1973
1988
  + _directive_prompt(
1974
1989
  completion_criteria=runtime_state.completion_criteria,
@@ -2000,6 +2015,7 @@ class RunnerRalphActivities:
2000
2015
  work_record_id=request.work_record_id,
2001
2016
  directive_number=request.directive_number,
2002
2017
  result=codex_result,
2018
+ auth_mode=attempt.auth_mode,
2003
2019
  )
2004
2020
  plan = _take_plan(checkout_workspace)
2005
2021
  if codex_result.exit_code != 0:
@@ -2419,6 +2435,8 @@ class RunnerRalphActivities:
2419
2435
  the bearer defensible under ADR-0011 §9.
2420
2436
  """
2421
2437
 
2438
+ auth_mode = await self._directive_auth_mode(work_record_id, state)
2439
+ await self._report_login_residue(work_record_id, state)
2422
2440
  facts = AttemptFacts(
2423
2441
  work_record_id=work_record_id,
2424
2442
  directive_id=directive_id(
@@ -2476,8 +2494,8 @@ class RunnerRalphActivities:
2476
2494
  )
2477
2495
  )
2478
2496
  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
2497
+ if self._llm_proxy is not None and auth_mode == AuthMode.API_KEY:
2498
+ # API-key mode only (issue 43): a subscription Directive never has a slot
2481
2499
  # to resolve, and issue 31 meters it from the harness's own output. One
2482
2500
  # bearer serves the callback socket and the proxy alike.
2483
2501
  proxy_attempt = await stack.enter_async_context(
@@ -2528,6 +2546,7 @@ class RunnerRalphActivities:
2528
2546
  mcp_servers=mcp_servers,
2529
2547
  egress_allow_list=egress_allow_list,
2530
2548
  skills_preamble=delivered_preamble,
2549
+ auth_mode=auth_mode,
2531
2550
  )
2532
2551
 
2533
2552
  async def _refuse_skill_digest_mismatch(
@@ -2805,26 +2824,86 @@ class RunnerRalphActivities:
2805
2824
  )
2806
2825
  raise
2807
2826
 
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.
2827
+ async def _directive_auth_mode(
2828
+ self, work_record_id: str, state: _RuntimeContextState
2829
+ ) -> AuthMode:
2830
+ """This Directive's auth mode, or a non-retryable refusal with Evidence naming the
2831
+ rule (local-agents 04).
2832
+
2833
+ Decided from the host party this process was registered with and from whether a
2834
+ key is present for the Contract -- never from the payload. A runtime that declares
2835
+ no modes (a test double built before the port grew the field) reads as
2836
+ ``api_key``-only, and a kind this Runner does not serve reads the same way; that
2837
+ Directive fails closed in :meth:`_agent_runtime_for` regardless.
2815
2838
  """
2816
2839
 
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
2840
  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
2841
+ host_party = self._routing_identity.host_party if self._routing_identity else None
2842
+ try:
2843
+ return choose_auth_mode(
2844
+ host_party=host_party,
2845
+ key_present=self._api_key_present(state, runtime),
2846
+ runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
2847
+ )
2848
+ except AuthModeRefusedError as refusal:
2849
+ await _raise_recorded(
2850
+ ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
2851
+ self._fastapi_client,
2852
+ work_record_id,
2853
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
2854
+ actor=self._actor(),
2855
+ payload={
2856
+ "event": "directive.auth_mode_refused",
2857
+ "rule": refusal.rule,
2858
+ "cli_kind": state.cli_kind,
2859
+ "host_party": host_party,
2860
+ "contract_id": state.contract_id,
2861
+ },
2862
+ )
2863
+
2864
+ def _api_key_present(self, state: _RuntimeContextState, runtime: AgentRuntime | None) -> bool:
2865
+ """A delivered slot for the Contract (or its funder), or the host operator's key."""
2866
+
2867
+ if bool(getattr(runtime, "host_api_key", False)):
2868
+ return True
2869
+ contract_id = _optional_uuid(state.contract_id)
2870
+ return (
2871
+ self._llm_proxy is not None
2872
+ and contract_id is not None
2873
+ and self._llm_proxy.slots.holds(contract_id)
2874
+ )
2875
+
2876
+ async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
2877
+ """On a shared Runner, say once that a Contract's harness root holds a login.
2878
+
2879
+ A count per harness, never a path or a byte of the file: the Runner does not open
2880
+ it, a shared Runner never runs on it, and ``wipe_contract_residue`` removes it with
2881
+ the Contract. Saying so is what turns a sign-in made before local-agents 03 (or by
2882
+ hand) into something the Organisation can see and act on.
2883
+ """
2884
+
2885
+ host_party = self._routing_identity.host_party if self._routing_identity else None
2886
+ if (
2887
+ self._contract_isolation is None
2888
+ or not is_shared_runner(host_party)
2889
+ or state.contract_id in self._residue_checked
2890
+ ):
2891
+ return
2892
+ self._residue_checked.add(state.contract_id)
2893
+ counts = login_file_counts(self._contract_isolation, state.contract_id)
2894
+ if not counts:
2895
+ return
2896
+ await self._fastapi_client.append_evidence(
2897
+ work_record_id,
2898
+ source=HARNESS_LOGIN_RESIDUE_SOURCE,
2899
+ actor=self._actor(),
2900
+ payload={
2901
+ "event": "harness.login_residue",
2902
+ "contract_id": state.contract_id,
2903
+ "host_party": host_party,
2904
+ "login_files": counts,
2905
+ },
2906
+ )
2828
2907
 
2829
2908
  async def _agent_runtime_for(
2830
2909
  self, work_record_id: str, state: _RuntimeContextState
@@ -4516,6 +4595,7 @@ class RunnerRalphActivities:
4516
4595
  extra_env=attempt.env,
4517
4596
  mcp_servers=attempt.mcp_servers,
4518
4597
  egress_allow_list=attempt.egress_allow_list,
4598
+ auth_mode=attempt.auth_mode,
4519
4599
  prompt=attempt.skills_preamble
4520
4600
  + _learning_prompt(
4521
4601
  request,
@@ -4534,6 +4614,7 @@ class RunnerRalphActivities:
4534
4614
  work_record_id=request.work_record_id,
4535
4615
  directive_number=request.directive_number,
4536
4616
  result=result,
4617
+ auth_mode=attempt.auth_mode,
4537
4618
  )
4538
4619
  usage = await self._directive_usage(
4539
4620
  work_record_id=request.work_record_id,
@@ -4764,6 +4845,7 @@ class RunnerRalphActivities:
4764
4845
  extra_env=attempt.env,
4765
4846
  mcp_servers=attempt.mcp_servers,
4766
4847
  egress_allow_list=attempt.egress_allow_list,
4848
+ auth_mode=attempt.auth_mode,
4767
4849
  prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
4768
4850
  base_branch=request.base_ref,
4769
4851
  work_branch=state.work_branch,
@@ -4789,6 +4871,7 @@ class RunnerRalphActivities:
4789
4871
  work_record_id=request.work_record_id,
4790
4872
  directive_number=request.directive_number,
4791
4873
  result=codex_result,
4874
+ auth_mode=attempt.auth_mode,
4792
4875
  )
4793
4876
  # Taken before the commit below so it never lands on the branch (PRD 59).
4794
4877
  plan = _take_plan(state.workspace_path)
@@ -4987,14 +5070,15 @@ class RunnerRalphActivities:
4987
5070
  work_record_id: str,
4988
5071
  directive_number: int,
4989
5072
  result: DirectiveResult,
5073
+ auth_mode: AuthMode,
4990
5074
  ) -> 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`).
5075
+ """A subscription Directive bypasses the LLM proxy, so this is the only metering
5076
+ the Runner can produce for it (PRD issue 31, 17 A9). A no-op for an ``api_key``
5077
+ Directive -- the proxy already metered that call at request time -- and never the
5078
+ reason a Directive fails (same posture as :meth:`_directive_usage`).
4995
5079
  """
4996
5080
 
4997
- if self._auth_model(runtime_state) == AuthModel.API_KEY:
5081
+ if auth_mode == AuthMode.API_KEY:
4998
5082
  return
4999
5083
  directive = directive_id(work_record_id=work_record_id, directive_number=directive_number)
5000
5084
  if runtime_state.cli_kind == "claude_code":
@@ -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)
@@ -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
+ )
@@ -35,7 +35,7 @@ import contextlib
35
35
  import json
36
36
  import secrets
37
37
  from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
38
- from dataclasses import dataclass, field
38
+ from dataclasses import dataclass, field, replace
39
39
  from datetime import UTC, datetime
40
40
  from types import TracebackType
41
41
  from typing import Any, Final, Self
@@ -67,25 +67,35 @@ __all__ = [
67
67
  "ATTEMPT_PREFIX",
68
68
  "COMPLETION_PATHS",
69
69
  "CALL_REFUSED_SOURCE",
70
+ "PROXY_API_KEY_ENV",
71
+ "PROXY_BASE_URL_ENV",
70
72
  "PROXY_ENV_NAMES",
71
73
  "AttemptHandle",
72
74
  "CeilingStore",
73
75
  "Ceilings",
74
76
  "CredentialSlot",
77
+ "LLM_SLOT_REFERENCES",
78
+ "LlmProvider",
75
79
  "LlmProxy",
76
80
  "SLOT_REFUSED_SOURCE",
77
81
  "SLOT_SWAPPED_SOURCE",
78
82
  "SlotStore",
79
83
  "UsageOutbox",
80
84
  "attempt_env",
85
+ "llm_slot_references",
81
86
  ]
82
87
 
83
88
  ATTEMPT_PREFIX: Final[str] = "/a/"
84
89
 
90
+ # The OpenAI-compatible half of `attempt_env`. Codex does not read the base URL from the
91
+ # environment, so its runtime lifts it from here into a provider of its own.
92
+ PROXY_BASE_URL_ENV: Final[str] = "OPENAI_BASE_URL"
93
+ PROXY_API_KEY_ENV: Final[str] = "OPENAI_API_KEY"
94
+
85
95
  # Every name `attempt_env` may set. Reserved on a Directive's environment so no hook
86
96
  # can redirect an Agent's traffic away from the metering point (`_runtime_support`).
87
97
  PROXY_ENV_NAMES: Final[frozenset[str]] = frozenset(
88
- {"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_KEY"}
98
+ {"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", PROXY_BASE_URL_ENV, PROXY_API_KEY_ENV}
89
99
  )
90
100
 
91
101
  # A Directive's context is the whole reason these bodies are large: Claude Code and Codex
@@ -105,10 +115,12 @@ _TOKEN_BYTES: Final[int] = 32
105
115
  _LOOPBACK_HOSTS: Final[frozenset[str]] = frozenset({"127.0.0.1", "::1", "localhost"})
106
116
 
107
117
  # The completion routes, relayed to the same path at the slot's own base URL: the
108
- # OpenAI-compatible one the backend proxy served, and Anthropic's, because Claude Code
109
- # is one of the two harnesses the platform ships and it would otherwise still need a
110
- # provider key of its own (ADR-0011 §9).
111
- COMPLETION_PATHS: Final[frozenset[str]] = frozenset({"/chat/completions", "/messages"})
118
+ # OpenAI-compatible one the backend proxy served, Anthropic's, because Claude Code is one
119
+ # of the two harnesses the platform ships and it would otherwise still need a provider key
120
+ # of its own (ADR-0011 §9), and the Responses API, which is the only wire Codex speaks.
121
+ COMPLETION_PATHS: Final[frozenset[str]] = frozenset(
122
+ {"/chat/completions", "/messages", "/responses"}
123
+ )
112
124
  _SSE_DONE: Final[bytes] = b"data: [DONE]"
113
125
 
114
126
  # Anthropic rejects a request without it, whichever credential shape the request carries.
@@ -183,6 +195,62 @@ class CredentialSlot:
183
195
  return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
184
196
 
185
197
 
198
+ @dataclass(frozen=True, slots=True)
199
+ class LlmProvider:
200
+ """Where a delivered key is spent, and how it is presented there.
201
+
202
+ Endpoints are plain config, never a secret (ADR-0013 §11), so the sealed wire does not
203
+ carry one: the Credential Reference's name says which provider a value belongs to and
204
+ this says where that provider is.
205
+ """
206
+
207
+ name: str
208
+ runtime_kind: str
209
+ base_url: str
210
+ auth_style: str
211
+
212
+ def slot(self, *, reference: str, key_id: str, value: str) -> CredentialSlot:
213
+ return CredentialSlot(
214
+ reference=reference,
215
+ key_id=key_id,
216
+ provider_name=self.name,
217
+ base_url=self.base_url,
218
+ value=value,
219
+ runtime_kind=self.runtime_kind,
220
+ auth_style=self.auth_style,
221
+ )
222
+
223
+
224
+ # The Credential References a delivered value fills the proxy's slot from (local-agents
225
+ # 04b), named after the variable each harness reads its own key from, so a funder declares
226
+ # the name the vendor's docs already taught them. Any other reference stays a verb-seam or
227
+ # MCP credential and never reaches the proxy.
228
+ LLM_SLOT_REFERENCES: Final[Mapping[str, LlmProvider]] = {
229
+ "OPENAI_API_KEY": LlmProvider(
230
+ name="openai",
231
+ runtime_kind="codex_cli",
232
+ base_url="https://api.openai.com/v1",
233
+ auth_style="bearer",
234
+ ),
235
+ "ANTHROPIC_API_KEY": LlmProvider(
236
+ name="anthropic",
237
+ runtime_kind="claude_code",
238
+ base_url="https://api.anthropic.com/v1",
239
+ auth_style="x-api-key",
240
+ ),
241
+ }
242
+
243
+
244
+ def llm_slot_references(base_urls: Mapping[str, str]) -> dict[str, LlmProvider]:
245
+ """:data:`LLM_SLOT_REFERENCES` with an operator's endpoint per provider name -- a
246
+ gateway in front of the vendor, or the chart test's fake provider."""
247
+
248
+ return {
249
+ reference: replace(provider, base_url=base_urls.get(provider.name, provider.base_url))
250
+ for reference, provider in LLM_SLOT_REFERENCES.items()
251
+ }
252
+
253
+
186
254
  SlotProber = Callable[[CredentialSlot], Awaitable[bool]]
187
255
 
188
256
 
@@ -267,6 +335,13 @@ class SlotStore:
267
335
  )
268
336
  return SlotProbe.VALID
269
337
 
338
+ def drop(self, contract_id: UUID) -> None:
339
+ """Forget a Contract's slot: wiped, not listed (22 A9). The next call is refused."""
340
+
341
+ self._slots.pop(contract_id, None)
342
+ self._probes.pop(contract_id, None)
343
+ self._last_used.pop(contract_id, None)
344
+
270
345
  def resolve(self, contract_id: UUID | None) -> CredentialSlot | None:
271
346
  """The slot this call spends, read at the call (22 A8)."""
272
347
 
@@ -281,6 +356,13 @@ class SlotStore:
281
356
  self._last_used[contract_id] = datetime.now(UTC)
282
357
  return slot
283
358
 
359
+ def holds(self, contract_id: UUID) -> bool:
360
+ """Whether a call for this Contract would find a slot -- without the use stamp
361
+ :meth:`resolve` leaves, because choosing a Directive's mode spends nothing."""
362
+
363
+ funder = self._funded_by.get(contract_id)
364
+ return contract_id in self._slots or (funder is not None and funder in self._slots)
365
+
284
366
  def statuses(self) -> list[SlotStatus]:
285
367
  """The heartbeat's slot fields (22 A10, issue 31's ``valid|invalid|unprobed``)."""
286
368
 
@@ -441,7 +523,7 @@ def attempt_env(cli_kind: str, *, base_url: str, token: str) -> dict[str, str]:
441
523
  # CLIs are configured with the `/v1` already on. Same attempt, same bearer — only
442
524
  # the half of the URL each harness expects to supply differs.
443
525
  return {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": token}
444
- return {"OPENAI_BASE_URL": f"{base_url}/v1", "OPENAI_API_KEY": token}
526
+ return {PROXY_BASE_URL_ENV: f"{base_url}/v1", PROXY_API_KEY_ENV: token}
445
527
 
446
528
 
447
529
  class LlmProxy:
@@ -863,13 +945,14 @@ def _usage_block(body: Mapping[str, object]) -> Mapping[str, object]:
863
945
  if isinstance(usage, Mapping):
864
946
  return usage
865
947
  # Anthropic's `message_start` frame nests the input counts one level down, under the
866
- # message it is starting. `message` is followed only to reach that `usage` block --
867
- # nothing else in the frame is read (ADR-0010 §4).
868
- message = body.get("message")
869
- if isinstance(message, Mapping):
870
- nested = message.get("usage")
871
- if isinstance(nested, Mapping):
872
- return nested
948
+ # message it is starting, and the Responses API's `response.completed` under the
949
+ # response it completes. Each is followed only to reach that `usage` block -- nothing
950
+ # else in the frame is read (ADR-0010 §4).
951
+ for inner in (body.get("message"), body.get("response")):
952
+ if isinstance(inner, Mapping):
953
+ nested = inner.get("usage")
954
+ if isinstance(nested, Mapping):
955
+ return nested
873
956
  return {}
874
957
 
875
958
 
@@ -931,7 +1014,8 @@ def _cached_tokens(usage: Mapping[str, object]) -> int | None:
931
1014
  direct = _first_int(usage, "cached_tokens", "cache_read_input_tokens")
932
1015
  if direct is not None:
933
1016
  return direct
934
- details = usage.get("prompt_tokens_details")
935
- if isinstance(details, Mapping):
936
- return _usage_int(details, "cached_tokens")
1017
+ for key in ("prompt_tokens_details", "input_tokens_details"):
1018
+ details = usage.get(key)
1019
+ if isinstance(details, Mapping):
1020
+ return _usage_int(details, "cached_tokens")
937
1021
  return None