agentic-runner 3.25.0__tar.gz → 3.26.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 (80) hide show
  1. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/PKG-INFO +2 -2
  2. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/pyproject.toml +1 -1
  3. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/activities.py +131 -23
  5. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/auth_mode.py +48 -11
  6. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/cli.py +25 -2
  7. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/llm_proxy.py +7 -1
  8. agentic_runner-3.26.0/src/agentic_runner/pool_key.py +175 -0
  9. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/service.py +76 -11
  10. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/control_plane.py +6 -0
  11. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/triage_activities.py +19 -2
  12. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/fastapi_client.py +14 -0
  13. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workstation.py +11 -0
  14. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/.gitignore +0 -0
  15. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/LICENSE +0 -0
  16. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/README.md +0 -0
  17. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/build.py +0 -0
  19. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/callback.py +0 -0
  20. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/child_watcher.py +0 -0
  21. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/cli_floor.py +0 -0
  22. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/config.py +0 -0
  23. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/credentials.py +0 -0
  24. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/egress.py +0 -0
  25. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/harness_self_test.py +0 -0
  26. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/heartbeat_link.py +0 -0
  27. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/hooks.py +0 -0
  28. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/host_store.py +0 -0
  29. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/__init__.py +0 -0
  30. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  31. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  32. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  33. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  34. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  35. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  36. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  37. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  38. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  39. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  40. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  41. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/launch.py +0 -0
  42. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/lifecycle.py +0 -0
  43. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/llm_contracts.py +0 -0
  44. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/mcp.py +0 -0
  45. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/message_store.py +0 -0
  46. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/oauth_connectors.py +0 -0
  47. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/private_state.py +0 -0
  48. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/py.typed +0 -0
  49. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  50. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/registration.py +0 -0
  51. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/runner_limit.py +0 -0
  52. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/runtime/__init__.py +0 -0
  53. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  54. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/sealed_box.py +0 -0
  55. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/sign_in.py +0 -0
  56. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/skill.py +0 -0
  57. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
  58. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/__init__.py +0 -0
  59. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/__main__.py +0 -0
  60. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/plugin.py +0 -0
  61. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  62. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/workflow.py +0 -0
  63. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/tiny_http.py +0 -0
  64. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/usage_windows.py +0 -0
  65. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/user_sources.py +0 -0
  66. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/__init__.py +0 -0
  67. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  68. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  69. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  70. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  71. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  72. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  73. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/command_policy.py +0 -0
  74. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  75. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/harness_logins.py +0 -0
  76. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  77. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  78. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  79. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/settings.py +0 -0
  80. {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.25.0
3
+ Version: 3.26.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
7
  Requires-Python: >=3.12
8
- Requires-Dist: agentic-runner-contracts==3.25.0
8
+ Requires-Dist: agentic-runner-contracts==3.26.0
9
9
  Requires-Dist: cryptography<51,>=50
10
10
  Requires-Dist: httpx<1.0,>=0.27
11
11
  Requires-Dist: pydantic-settings<3.0,>=2.0
@@ -10,7 +10,7 @@ dependencies = [
10
10
  # Exactly this release's contracts: the two ship as one version, and an unpinned
11
11
  # requirement let `uv tool upgrade` keep an older contracts wheel the Runner cannot
12
12
  # import from (QTS-1473). `scripts/set-version.py` moves it with the version.
13
- "agentic-runner-contracts==3.25.0",
13
+ "agentic-runner-contracts==3.26.0",
14
14
  # Imported directly by `registration.py` (the Ed25519 identity that signs every
15
15
  # heartbeat); already present transitively through pyjwt[crypto], declared because
16
16
  # we import it.
@@ -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.25.0"
10
+ __version__ = "3.26.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -36,6 +36,7 @@ from pathlib import Path
36
36
  from typing import Any, NoReturn, Protocol
37
37
  from uuid import UUID
38
38
 
39
+ from pydantic import ValidationError
39
40
  from temporalio import activity
40
41
  from temporalio.exceptions import ApplicationError
41
42
 
@@ -46,6 +47,8 @@ from agentic_runner.attempts import (
46
47
  refuse_if_prior_attempt_alive,
47
48
  )
48
49
  from agentic_runner.auth_mode import (
50
+ LLM_KEY_RELEASE_REFUSED_RULE,
51
+ LLM_KEY_UNOPENABLE_RULE,
49
52
  AuthModeRefusedError,
50
53
  choose_auth_mode,
51
54
  is_shared_runner,
@@ -115,7 +118,7 @@ from agentic_runner.integrations.git.workspace import (
115
118
  contract_workspace_path,
116
119
  )
117
120
  from agentic_runner.integrations.github.auth import GitHubAppAuthenticationError
118
- from agentic_runner.llm_proxy import LlmProxy
121
+ from agentic_runner.llm_proxy import CredentialSlot, LlmProxy
119
122
  from agentic_runner.mcp import (
120
123
  MCP_ASSEMBLY_SOURCE,
121
124
  McpPlan,
@@ -126,6 +129,7 @@ from agentic_runner.mcp import (
126
129
  plan_mcp,
127
130
  )
128
131
  from agentic_runner.message_store import MessageStore, WorkflowSignaller
132
+ from agentic_runner.pool_key import LlmKeyOpener, LlmKeyUnopenableError
129
133
  from agentic_runner.registration import RunnerRegistrationError
130
134
  from agentic_runner.runner_limit import (
131
135
  EMPTY_RUNNER_LIMIT,
@@ -159,6 +163,7 @@ from agentic_runner.workers.contract_isolation import (
159
163
  from agentic_runner.workers.fastapi_client import (
160
164
  DirectiveTokenSource,
161
165
  WorkerFastApiClientError,
166
+ WorkerFastApiClientRejectionError,
162
167
  directive_token,
163
168
  )
164
169
  from agentic_runner.workers.harness_logins import (
@@ -283,6 +288,7 @@ from agentic_runner_contracts.grants import (
283
288
  )
284
289
  from agentic_runner_contracts.grants.seam import VerbDecision, decide_verb
285
290
  from agentic_runner_contracts.llm_access import LlmAccess, LlmAccessKind, provider_for
291
+ from agentic_runner_contracts.llm_key import LlmKeyRelease, LlmKeyReleaseRequest
286
292
  from agentic_runner_contracts.questions import WORK_ASK_VERB
287
293
  from agentic_runner_contracts.redaction import redact_secret_like_text
288
294
  from agentic_runner_contracts.routing import (
@@ -292,7 +298,11 @@ from agentic_runner_contracts.routing import (
292
298
  assert_routed,
293
299
  selector_satisfied,
294
300
  )
295
- from agentic_runner_contracts.runner_registration import SubscriptionState
301
+ from agentic_runner_contracts.runner_registration import (
302
+ ContractLlmTerms,
303
+ SubscriptionRight,
304
+ SubscriptionState,
305
+ )
296
306
  from agentic_runner_contracts.runtime_context import (
297
307
  EGRESS_ALLOW_LIST_KEY,
298
308
  McpServerSpec,
@@ -552,6 +562,11 @@ class RunnerRalphFastApiClient(Protocol):
552
562
  ) -> dict[str, Any]:
553
563
  """Read one Directive's metered token aggregate (PRD issue 13)."""
554
564
 
565
+ async def release_llm_key(
566
+ self, work_record_id: str, llm_key_id: str, payload: Mapping[str, Any]
567
+ ) -> dict[str, Any]:
568
+ """The sealed shares of the pool's LLM Key a Directive was routed to (rev 3)."""
569
+
555
570
  async def report_harness_usage(self, payload: Mapping[str, Any]) -> list[dict[str, Any]]:
556
571
  """One Usage Record per model off a harness's own JSON (PRD issue 31, 17 A9)."""
557
572
 
@@ -1720,8 +1735,13 @@ class RunnerRalphActivities:
1720
1735
  directives_in_flight: DirectivesInFlight | None = None,
1721
1736
  usage_windows: UsageWindows | None = None,
1722
1737
  runner_limit: AppliedRunnerLimit | None = None,
1738
+ llm_contracts: Callable[[], Sequence[ContractLlmTerms] | None] | None = None,
1739
+ llm_keys: LlmKeyOpener | None = None,
1723
1740
  ) -> None:
1724
1741
  self._fastapi_client = fastapi_client
1742
+ # Opens the pool's LLM Keys with the Pool Key (Spec: Runners rev 3). None, or one
1743
+ # holding no Pool Key, fails every `llm_key` Directive closed.
1744
+ self._llm_keys = llm_keys
1725
1745
  # What this Runner's host denies every Agent on it, checked at start (ADR-0022
1726
1746
  # §52-§55). Empty by default, which narrows nothing.
1727
1747
  self._runner_limit = runner_limit or EMPTY_RUNNER_LIMIT
@@ -1794,6 +1814,9 @@ class RunnerRalphActivities:
1794
1814
  # The retry fence (runner-repo 05). None fences nothing: a process that never
1795
1815
  # registered has no state directory to keep the records in.
1796
1816
  self._attempt_records = attempt_records
1817
+ # The Contracts the last heartbeat ack named, read at each Directive (QTS-1751).
1818
+ # None, or a callable answering None, before any ack from a 3.25 control plane.
1819
+ self._llm_contracts = llm_contracts
1797
1820
 
1798
1821
  def activity_callables(self) -> list[Callable[..., Any]]:
1799
1822
  """Return decorated activity callables for Temporal worker registration."""
@@ -2659,12 +2682,18 @@ class RunnerRalphActivities:
2659
2682
 
2660
2683
  access = routing.llm_access if routing is not None else None
2661
2684
  auth_mode = await self._directive_auth_mode(work_record_id, state, access)
2685
+ attempt_directive_id = directive_id(
2686
+ work_record_id=work_record_id, directive_number=directive_number
2687
+ )
2688
+ llm_key = (
2689
+ await self._release_llm_key(work_record_id, state, access, attempt_directive_id)
2690
+ if access is not None and access.kind == LlmAccessKind.LLM_KEY
2691
+ else None
2692
+ )
2662
2693
  await self._report_login_residue(work_record_id, state)
2663
2694
  facts = AttemptFacts(
2664
2695
  work_record_id=work_record_id,
2665
- directive_id=directive_id(
2666
- work_record_id=work_record_id, directive_number=directive_number
2667
- ),
2696
+ directive_id=attempt_directive_id,
2668
2697
  contract_id=contract_path_segment(state.contract_id),
2669
2698
  agent_id=state.agent_id or "",
2670
2699
  persona=state.persona_slug or "",
@@ -2723,7 +2752,8 @@ class RunnerRalphActivities:
2723
2752
  # API-key mode only (issue 43): a subscription Directive never has a slot
2724
2753
  # to resolve, and issue 31 meters it from the harness's own output. One
2725
2754
  # bearer serves the callback socket and the proxy alike. The attempt spends
2726
- # its own provider's slot, and a `host_key` Directive the store's alone.
2755
+ # its own provider's slot, a `host_key` Directive the store's alone, and an
2756
+ # `llm_key` Directive the key released for it, which leaves with the attempt.
2727
2757
  proxy_attempt = await stack.enter_async_context(
2728
2758
  self._llm_proxy.attempt(
2729
2759
  directive_id=facts.directive_id,
@@ -2737,6 +2767,7 @@ class RunnerRalphActivities:
2737
2767
  access is not None and access.kind == LlmAccessKind.HOST_KEY
2738
2768
  ),
2739
2769
  delivered_only=access is None and not self._personal_runner(),
2770
+ llm_key=llm_key,
2740
2771
  )
2741
2772
  )
2742
2773
  callback_env.update(proxy_attempt.env(state.cli_kind))
@@ -3148,7 +3179,9 @@ class RunnerRalphActivities:
3148
3179
  host_party=host_party,
3149
3180
  host_key_held=self._host_key_held(state),
3150
3181
  subscription=self._subscription_state(state),
3182
+ subscription_right=self._subscription_right(state),
3151
3183
  runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
3184
+ pool_key_held=self._llm_keys is not None and self._llm_keys.holds_pool_key,
3152
3185
  )
3153
3186
  return choose_auth_mode(
3154
3187
  host_party=host_party,
@@ -3162,25 +3195,87 @@ class RunnerRalphActivities:
3162
3195
  ),
3163
3196
  )
3164
3197
  except AuthModeRefusedError as refusal:
3165
- await _raise_recorded(
3166
- ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
3167
- self._fastapi_client,
3198
+ await self._refuse_access(work_record_id, state, access, refusal)
3199
+
3200
+ async def _release_llm_key(
3201
+ self,
3202
+ work_record_id: str,
3203
+ state: _RuntimeContextState,
3204
+ access: LlmAccess,
3205
+ attempt_directive_id: str,
3206
+ ) -> CredentialSlot:
3207
+ """The pool's LLM Key this Directive was routed to, opened in memory (rev 3).
3208
+
3209
+ The control plane releases it only to a live Runner of the key's pool for a
3210
+ Directive in flight on it, which the Directive token proves. A refusal or a release
3211
+ that does not open fails the Directive closed, like any access the Runner no longer
3212
+ holds: a retry would be refused the same way.
3213
+ """
3214
+
3215
+ fingerprint = self._llm_keys.fingerprint if self._llm_keys is not None else None
3216
+ try:
3217
+ # Refused already by `require_named_access`; this narrows the types.
3218
+ if self._llm_keys is None or fingerprint is None:
3219
+ raise LlmKeyUnopenableError("this Runner holds no Pool Key")
3220
+ payload = await self._fastapi_client.release_llm_key(
3168
3221
  work_record_id,
3169
- source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3170
- actor=self._actor(),
3171
- payload={
3172
- "event": (
3173
- "directive.auth_mode_refused"
3174
- if access is None
3175
- else "directive.llm_access_refused"
3176
- ),
3177
- "rule": refusal.rule,
3178
- "cli_kind": state.cli_kind,
3179
- "host_party": host_party,
3180
- "contract_id": state.contract_id,
3181
- **({} if access is None else {"llm_access": access.kind}),
3182
- },
3222
+ access.llm_key_id,
3223
+ LlmKeyReleaseRequest(
3224
+ directive_id=attempt_directive_id, pool_key_fingerprint=fingerprint
3225
+ ).model_dump(mode="json"),
3183
3226
  )
3227
+ try:
3228
+ release = LlmKeyRelease.model_validate(payload)
3229
+ except ValidationError as error:
3230
+ unreadable = "the control plane released an LLM Key this Runner cannot read"
3231
+ raise LlmKeyUnopenableError(unreadable) from error
3232
+ if release.llm_key_id != access.llm_key_id or release.provider != provider_for(
3233
+ state.cli_kind
3234
+ ):
3235
+ raise LlmKeyUnopenableError(
3236
+ f"the control plane released LLM Key {release.llm_key_id} for "
3237
+ f"{release.provider.value}, "
3238
+ f"not {access.llm_key_id} for the Directive's {state.cli_kind}"
3239
+ )
3240
+ return self._llm_keys.open(release)
3241
+ except WorkerFastApiClientRejectionError as error:
3242
+ refusal = AuthModeRefusedError(
3243
+ LLM_KEY_RELEASE_REFUSED_RULE,
3244
+ f"the control plane refused to release LLM Key {access.llm_key_id} to this Runner "
3245
+ f"({error.status_code})",
3246
+ )
3247
+ except LlmKeyUnopenableError as error:
3248
+ refusal = AuthModeRefusedError(LLM_KEY_UNOPENABLE_RULE, str(error))
3249
+ await self._refuse_access(work_record_id, state, access, refusal)
3250
+
3251
+ async def _refuse_access(
3252
+ self,
3253
+ work_record_id: str,
3254
+ state: _RuntimeContextState,
3255
+ access: LlmAccess | None,
3256
+ refusal: AuthModeRefusedError,
3257
+ ) -> NoReturn:
3258
+ await _raise_recorded(
3259
+ ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
3260
+ self._fastapi_client,
3261
+ work_record_id,
3262
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3263
+ actor=self._actor(),
3264
+ payload={
3265
+ "event": (
3266
+ "directive.auth_mode_refused"
3267
+ if access is None
3268
+ else "directive.llm_access_refused"
3269
+ ),
3270
+ "rule": refusal.rule,
3271
+ "cli_kind": state.cli_kind,
3272
+ "host_party": (
3273
+ self._routing_identity.host_party if self._routing_identity else None
3274
+ ),
3275
+ "contract_id": state.contract_id,
3276
+ **({} if access is None else {"llm_access": access.kind}),
3277
+ },
3278
+ )
3184
3279
 
3185
3280
  def _runs_under_contract_uid(self, state: _RuntimeContextState) -> bool:
3186
3281
  """Whether this Directive spawns as its own Contract's uid. A Work Record with no
@@ -3219,6 +3314,19 @@ class RunnerRalphActivities:
3219
3314
  and self._llm_proxy.slots.holds_host(provider.value)
3220
3315
  )
3221
3316
 
3317
+ def _subscription_right(self, state: _RuntimeContextState) -> SubscriptionRight | None:
3318
+ """What the last ack said of this Contract's own subscription; ``no_right`` for a
3319
+ Contract it did not name, since nothing then allowed one here."""
3320
+
3321
+ terms = self._llm_contracts() if self._llm_contracts is not None else None
3322
+ if terms is None:
3323
+ return None
3324
+ contract_id = _optional_uuid(state.contract_id)
3325
+ return next(
3326
+ (term.subscription for term in terms if term.contract_id == contract_id),
3327
+ SubscriptionRight.NO_RIGHT,
3328
+ )
3329
+
3222
3330
  def _subscription_state(self, state: _RuntimeContextState) -> SubscriptionState:
3223
3331
  if self._contract_isolation is None or not state.contract_id:
3224
3332
  return SubscriptionState.NONE
@@ -30,15 +30,19 @@ from typing import Final
30
30
 
31
31
  from agentic_runner.workers.agent_runtime import AuthMode
32
32
  from agentic_runner_contracts.llm_access import LlmAccess, LlmAccessKind
33
- from agentic_runner_contracts.runner_registration import SubscriptionState
33
+ from agentic_runner_contracts.runner_registration import SubscriptionRight, SubscriptionState
34
34
 
35
35
  __all__ = [
36
36
  "NO_RUNTIME_SUBSCRIPTION_RULE",
37
37
  "HOST_KEY_NOT_HELD_RULE",
38
38
  "LLM_ACCESS_UNKNOWN_RULE",
39
- "LLM_KEY_UNSUPPORTED_RULE",
39
+ "LLM_KEY_RELEASE_REFUSED_RULE",
40
+ "LLM_KEY_UNOPENABLE_RULE",
41
+ "POOL_KEY_NOT_HELD_RULE",
40
42
  "SETUP_TOKEN_REFUSED_RULE",
43
+ "SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE",
41
44
  "SUBSCRIPTION_NOT_SIGNED_IN_RULE",
45
+ "SUBSCRIPTION_NO_RIGHT_RULE",
42
46
  "SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE",
43
47
  "SHARED_HOST_PARTIES",
44
48
  "SIGN_IN_NEEDS_CONTRACT_UID_RULE",
@@ -64,7 +68,14 @@ SETUP_TOKEN_REFUSED_RULE: Final[str] = "setup_token_refused"
64
68
  HOST_KEY_NOT_HELD_RULE: Final[str] = "host_key_not_held"
65
69
  SUBSCRIPTION_NOT_SIGNED_IN_RULE: Final[str] = "subscription_not_signed_in"
66
70
  SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE: Final[str] = "subscription_off_personal_runner"
67
- LLM_KEY_UNSUPPORTED_RULE: Final[str] = "llm_key_unsupported"
71
+ SUBSCRIPTION_NO_RIGHT_RULE: Final[str] = "subscription_no_right"
72
+ SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE: Final[str] = "subscription_not_acknowledged"
73
+ # An `llm_key` Directive on a Runner given no Pool Key; one whose release the control plane
74
+ # refused; and
75
+ # one whose release does not open here (an old Pool Key, or a share bound to another key).
76
+ POOL_KEY_NOT_HELD_RULE: Final[str] = "pool_key_not_held"
77
+ LLM_KEY_RELEASE_REFUSED_RULE: Final[str] = "llm_key_release_refused"
78
+ LLM_KEY_UNOPENABLE_RULE: Final[str] = "llm_key_unopenable"
68
79
  LLM_ACCESS_UNKNOWN_RULE: Final[str] = "llm_access_unknown"
69
80
  # `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
70
81
  # `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
@@ -148,14 +159,24 @@ def require_named_access(
148
159
  host_party: str | None,
149
160
  host_key_held: bool,
150
161
  subscription: SubscriptionState,
162
+ subscription_right: SubscriptionRight | None,
151
163
  runtime_modes: Collection[AuthMode],
164
+ pool_key_held: bool,
152
165
  ) -> AuthMode:
153
166
  """The mode the named access runs in, or a refusal naming what is missing.
154
167
 
155
168
  ``host_key_held`` is the provider's key in this Runner's own store, serving in the
156
- proxy; ``subscription`` the Contract's harness root for the Directive's kind. A
157
- subscription runs only on its person's own Runner (rev 3: never on a pool Runner),
158
- and only while it is signed in, unexpired and not at its usage limit.
169
+ proxy; ``subscription`` the Contract's harness root for the Directive's kind;
170
+ ``pool_key_held`` whether this Runner was given a Pool Key, without which none of the
171
+ pool's LLM Keys opens here. A subscription runs only on its person's own Runner (rev 3:
172
+ never on a pool Runner), and only while it is signed in, unexpired and not at its
173
+ usage limit.
174
+
175
+ ``subscription_right`` is what the last heartbeat ack said of the Contract's own
176
+ subscription (``llm_contracts``), so a misrouted Directive is refused here as well as
177
+ at the platform's routing (QTS-1349 controls 4 and 7, QTS-1751). ``None`` while no ack
178
+ has named the Contracts -- a control plane below 3.25 -- which leaves routing the only
179
+ check, as before.
159
180
  """
160
181
 
161
182
  if access.kind == LlmAccessKind.HOST_KEY:
@@ -173,6 +194,19 @@ def require_named_access(
173
194
  f"this Runner is hosted by {host_party!r}; a subscription runs only on its "
174
195
  "person's own Runner",
175
196
  )
197
+ if subscription_right == SubscriptionRight.NO_RIGHT:
198
+ raise AuthModeRefusedError(
199
+ SUBSCRIPTION_NO_RIGHT_RULE,
200
+ "this Directive was routed to the person's subscription, and the control plane "
201
+ "last told this Runner that the Contract does not allow one",
202
+ )
203
+ if subscription_right == SubscriptionRight.NOT_ACKNOWLEDGED:
204
+ raise AuthModeRefusedError(
205
+ SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE,
206
+ "this Directive was routed to the person's subscription, and the control plane "
207
+ "last told this Runner that the person has not acknowledged the subscription "
208
+ "terms",
209
+ )
176
210
  if AuthMode.SUBSCRIPTION not in runtime_modes:
177
211
  raise AuthModeRefusedError(
178
212
  NO_RUNTIME_SUBSCRIPTION_RULE,
@@ -186,11 +220,14 @@ def require_named_access(
186
220
  )
187
221
  return AuthMode.SUBSCRIPTION
188
222
  if access.kind == LlmAccessKind.LLM_KEY:
189
- raise AuthModeRefusedError(
190
- LLM_KEY_UNSUPPORTED_RULE,
191
- "this Directive was routed to one of the pool's LLM Keys, which this Runner "
192
- "release cannot open; update the Runner",
193
- )
223
+ if not pool_key_held:
224
+ raise AuthModeRefusedError(
225
+ POOL_KEY_NOT_HELD_RULE,
226
+ "this Directive was routed to one of the pool's LLM Keys, and this Runner "
227
+ "holds no Pool Key; give it the one on the Runners page as "
228
+ "AGENTIC_RUNNER_POOL_KEY",
229
+ )
230
+ return AuthMode.API_KEY
194
231
  raise AuthModeRefusedError(
195
232
  LLM_ACCESS_UNKNOWN_RULE, f"this Runner does not know the LLM access {access.kind!r}"
196
233
  )
@@ -54,6 +54,7 @@ from agentic_runner.callback import (
54
54
  )
55
55
  from agentic_runner.config import load as load_config
56
56
  from agentic_runner.host_store import open_workstation_store
57
+ from agentic_runner.pool_key import POOL_KEY_ENV, PoolKeyError
57
58
  from agentic_runner.recipient_key_secret import ensure_recipient_key, in_cluster
58
59
  from agentic_runner.registration import RunnerRegistrationError, can_separate_uids, load_state
59
60
  from agentic_runner.sealed_box import RECIPIENT_KEY_FILENAME
@@ -184,6 +185,12 @@ def main(argv: Sequence[str] | None = None) -> int:
184
185
  help="comma-separated cli_kinds served through the pinned ACP bridge (codex_cli, "
185
186
  "claude_code); omitted keeps the previous install's, '' turns ACP off",
186
187
  )
188
+ install.add_argument(
189
+ "--pool-key",
190
+ action="store_true",
191
+ help=f"ask for the Pool Key of this Runner's pool (or set {POOL_KEY_ENV}), to open the "
192
+ "pool's LLM Keys",
193
+ )
187
194
  install.add_argument("--no-start", action="store_true", help="write the agent, do not start")
188
195
  install.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
189
196
  for service_verb in ("start", "stop"):
@@ -503,6 +510,13 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
503
510
  "Runner Registration Token (yours from /me/runners, or the Organisation Admin's for "
504
511
  "an Organisation Runner): "
505
512
  )
513
+ # Pasted too, never a flag value, for the token's reason. Taken out of the environment so
514
+ # the Runner this install starts reads it from the state directory, where it is kept.
515
+ pool_key = os.environ.pop(POOL_KEY_ENV, "").strip() or (
516
+ getpass.getpass("Pool Key (shown once on the Organisation's Runners page): ")
517
+ if arguments.pool_key
518
+ else None
519
+ )
506
520
  settings = workstation.WorkstationSettings(
507
521
  org=paths.org,
508
522
  control_plane_url=arguments.control_plane,
@@ -515,10 +529,19 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
515
529
  try:
516
530
  lines = asyncio.run(
517
531
  workstation.install(
518
- paths, settings, registration_token=token, start_service=not arguments.no_start
532
+ paths,
533
+ settings,
534
+ registration_token=token,
535
+ pool_key=pool_key,
536
+ start_service=not arguments.no_start,
519
537
  )
520
538
  )
521
- except (workstation.CliMissingError, RunnerRegistrationError, RuntimeError) as error:
539
+ except (
540
+ workstation.CliMissingError,
541
+ RunnerRegistrationError,
542
+ PoolKeyError,
543
+ RuntimeError,
544
+ ) as error:
522
545
  print(f"install refused: {error}", file=sys.stderr)
523
546
  return 1
524
547
  for line in lines:
@@ -580,6 +580,10 @@ class AttemptHandle:
580
580
  host_key_only: bool = False
581
581
  # A pool Runner's Directive that names no access (`SlotStore.resolve`).
582
582
  delivered_only: bool = False
583
+ # The pool's LLM Key released for this Directive (Spec: Runners rev 3): held here and
584
+ # nowhere else, so it is spent by this attempt only and dropped with it. Never in the
585
+ # slot store, which serves every attempt and is reported on the heartbeat.
586
+ llm_key: CredentialSlot | None = field(default=None, repr=False)
583
587
 
584
588
  def env(self, cli_kind: str) -> dict[str, str]:
585
589
  return attempt_env(cli_kind, base_url=self.base_url, token=self.token)
@@ -676,6 +680,7 @@ class LlmProxy:
676
680
  provider: str | None = None,
677
681
  host_key_only: bool = False,
678
682
  delivered_only: bool = False,
683
+ llm_key: CredentialSlot | None = None,
679
684
  ) -> AsyncIterator[AttemptHandle]:
680
685
  """Admit one Directive attempt for as long as it runs, and no longer.
681
686
 
@@ -697,6 +702,7 @@ class LlmProxy:
697
702
  provider=provider,
698
703
  host_key_only=host_key_only,
699
704
  delivered_only=delivered_only,
705
+ llm_key=llm_key,
700
706
  )
701
707
  self._attempts[attempt_id] = handle
702
708
  try:
@@ -738,7 +744,7 @@ class LlmProxy:
738
744
  await write_json(writer, 404, {"detail": "no such proxy route"})
739
745
  return
740
746
 
741
- slot = self.slots.resolve(
747
+ slot = attempt.llm_key or self.slots.resolve(
742
748
  attempt.contract_id,
743
749
  attempt.provider,
744
750
  host_key_only=attempt.host_key_only,
@@ -0,0 +1,175 @@
1
+ """The pool's Pool Key on this Runner, and the LLM Keys it opens (Spec: Runners rev 3).
2
+
3
+ An Organisation's Admin creates the Pool Key in the console, which shows its private half
4
+ once, and gives it to every Runner of the pool at install: one chart Secret for all
5
+ replicas, a Docker env file, or ``agentic-runner install --pool-key`` on a workstation.
6
+ The Runner reads it once at start and holds it in memory.
7
+
8
+ It is kept apart from the Runner's credential store on purpose. Every value in that store
9
+ can be named by a Contract's credential manifest and handed to a verb seam or an MCP
10
+ server; the Pool Key must reach nothing but :class:`LlmKeyOpener`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import binascii
17
+ import os
18
+ from collections.abc import Mapping, MutableMapping
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Final
22
+
23
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
24
+
25
+ from agentic_runner.llm_proxy import LLM_SLOT_REFERENCES, CredentialSlot, LlmEndpoint
26
+ from agentic_runner.private_state import private_read, private_write
27
+ from agentic_runner.sealed_box import RecipientKeyStore, SealedCredentialError, open_sealed
28
+ from agentic_runner_contracts.llm_access import PROVIDER_KEY_NAMES
29
+ from agentic_runner_contracts.llm_key import LlmKeyRelease, join_llm_key
30
+ from agentic_runner_contracts.sealed_credential import key_fingerprint
31
+
32
+ __all__ = [
33
+ "POOL_KEY_ENV",
34
+ "POOL_KEY_FILENAME",
35
+ "LlmKeyOpener",
36
+ "LlmKeyUnopenableError",
37
+ "PoolKey",
38
+ "PoolKeyError",
39
+ "load_pool_key",
40
+ "parse_pool_key",
41
+ "save_pool_key",
42
+ ]
43
+
44
+ POOL_KEY_ENV: Final[str] = "AGENTIC_RUNNER_POOL_KEY"
45
+ # Where a workstation install keeps it, 0600 beside the Runner's identity.
46
+ POOL_KEY_FILENAME: Final[str] = "pool-key"
47
+
48
+ _KEY_BYTES: Final[int] = 32
49
+
50
+
51
+ class PoolKeyError(ValueError):
52
+ """The Pool Key given at install is not one."""
53
+
54
+
55
+ class LlmKeyUnopenableError(ValueError):
56
+ """A released LLM Key does not open on this Runner."""
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class PoolKey:
61
+ private_key: str = field(repr=False)
62
+ public_key: str
63
+
64
+ @property
65
+ def fingerprint(self) -> str:
66
+ return key_fingerprint(self.public_key)
67
+
68
+
69
+ def parse_pool_key(value: str) -> PoolKey:
70
+ """The X25519 private half the console showed, base64, as a :class:`PoolKey`."""
71
+
72
+ try:
73
+ raw = base64.b64decode(value.strip(), validate=True)
74
+ except (binascii.Error, ValueError) as error:
75
+ raise PoolKeyError(f"{POOL_KEY_ENV} must be the base64 Pool Key the console showed") from (
76
+ error
77
+ )
78
+ if len(raw) != _KEY_BYTES:
79
+ raise PoolKeyError(f"{POOL_KEY_ENV} must be a 32-byte Pool Key")
80
+ public = X25519PrivateKey.from_private_bytes(raw).public_key().public_bytes_raw()
81
+ return PoolKey(
82
+ private_key=base64.b64encode(raw).decode("ascii"),
83
+ public_key=base64.b64encode(public).decode("ascii"),
84
+ )
85
+
86
+
87
+ def load_pool_key(
88
+ state_dir: Path | None, environ: MutableMapping[str, str] = os.environ
89
+ ) -> PoolKey | None:
90
+ """The Pool Key from the environment, else the workstation install's file; None if
91
+ the Runner was given none.
92
+
93
+ Taken out of the environment as it is read: a child process started without an
94
+ explicit environment would otherwise inherit it.
95
+ """
96
+
97
+ value = environ.pop(POOL_KEY_ENV, "").strip()
98
+ if not value and state_dir is not None:
99
+ stored = private_read(state_dir / POOL_KEY_FILENAME)
100
+ value = stored.decode("utf-8").strip() if stored is not None else ""
101
+ return parse_pool_key(value) if value else None
102
+
103
+
104
+ def save_pool_key(state_dir: Path, value: str) -> PoolKey:
105
+ """Check and keep a workstation's Pool Key, for every later start of its Runner."""
106
+
107
+ pool_key = parse_pool_key(value)
108
+ private_write(state_dir / POOL_KEY_FILENAME, pool_key.private_key.encode("ascii"))
109
+ return pool_key
110
+
111
+
112
+ class LlmKeyOpener:
113
+ """Opens a released LLM Key into one proxy slot (Spec: Runners rev 3).
114
+
115
+ The slot is handed to one Directive's proxy attempt and to nothing else, so the key
116
+ lives exactly as long as that attempt: never in the proxy's slot store, never on
117
+ disk, never in the Agent Runtime's environment.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ *,
123
+ pool_key: PoolKey | None,
124
+ recipient_keys: RecipientKeyStore,
125
+ endpoints: Mapping[str, LlmEndpoint] = LLM_SLOT_REFERENCES,
126
+ ) -> None:
127
+ self._pool_key = pool_key
128
+ self._recipient_keys = recipient_keys
129
+ self._endpoints = endpoints
130
+
131
+ @property
132
+ def fingerprint(self) -> str | None:
133
+ return self._pool_key.fingerprint if self._pool_key is not None else None
134
+
135
+ @property
136
+ def holds_pool_key(self) -> bool:
137
+ return self._pool_key is not None
138
+
139
+ def open(self, release: LlmKeyRelease) -> CredentialSlot:
140
+ """The key the two shares make, as a slot for the release's provider.
141
+
142
+ One error for every way a release fails to open, as :func:`open_sealed` does: a
143
+ share sealed to an older Pool Key, to another Runner or bound to another key are
144
+ the same answer to the holder, and telling them apart would be an oracle.
145
+ """
146
+
147
+ unopenable = f"LLM Key {release.llm_key_id} does not open with this Runner's keys"
148
+ if self._pool_key is None or release.pool_key_fingerprint != self._pool_key.fingerprint:
149
+ raise LlmKeyUnopenableError(unopenable)
150
+ try:
151
+ pool_share = open_sealed(
152
+ private_key=self._pool_key.private_key,
153
+ binding=release.pool_share_binding(),
154
+ ciphertext=release.pool_share,
155
+ )
156
+ platform_share = open_sealed(
157
+ private_key=self._recipient_private_key(release.recipient_key_id),
158
+ binding=release.platform_share_binding(),
159
+ ciphertext=release.platform_share,
160
+ )
161
+ value = join_llm_key(pool_share, platform_share)
162
+ except (SealedCredentialError, ValueError) as error:
163
+ raise LlmKeyUnopenableError(unopenable) from error
164
+ key_name = PROVIDER_KEY_NAMES[release.provider]
165
+ return self._endpoints[key_name].slot(
166
+ reference=key_name, key_id=release.llm_key_id, value=value
167
+ )
168
+
169
+ def _recipient_private_key(self, recipient_key_id: str) -> str:
170
+ # The previous key too: the control plane may have sealed its share just before a
171
+ # renewal.
172
+ for pair in (self._recipient_keys.current(), self._recipient_keys.previous()):
173
+ if pair is not None and pair.key_id == recipient_key_id:
174
+ return pair.private_key
175
+ raise SealedCredentialError("the platform share is sealed to another Recipient Key")