agentic-runner 3.25.1__tar.gz → 3.26.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/PKG-INFO +2 -2
  2. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/pyproject.toml +1 -1
  3. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/activities.py +108 -22
  5. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/auth_mode.py +23 -10
  6. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/cli.py +25 -2
  7. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/llm_proxy.py +7 -1
  8. agentic_runner-3.26.1/src/agentic_runner/pool_key.py +175 -0
  9. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/service.py +67 -11
  10. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/sign_in.py +42 -35
  11. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/testing/control_plane.py +6 -0
  12. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/fastapi_client.py +14 -0
  13. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workstation.py +11 -0
  14. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/.gitignore +0 -0
  15. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/LICENSE +0 -0
  16. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/README.md +0 -0
  17. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/build.py +0 -0
  19. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/callback.py +0 -0
  20. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/child_watcher.py +0 -0
  21. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/cli_floor.py +0 -0
  22. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/config.py +0 -0
  23. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/credentials.py +0 -0
  24. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/egress.py +0 -0
  25. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/harness_self_test.py +0 -0
  26. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/heartbeat_link.py +0 -0
  27. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/hooks.py +0 -0
  28. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/host_store.py +0 -0
  29. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/__init__.py +0 -0
  30. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  31. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  32. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  33. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  34. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  35. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  36. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  37. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  38. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  39. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  40. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  41. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/launch.py +0 -0
  42. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/lifecycle.py +0 -0
  43. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/llm_contracts.py +0 -0
  44. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/mcp.py +0 -0
  45. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/message_store.py +0 -0
  46. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/oauth_connectors.py +0 -0
  47. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/private_state.py +0 -0
  48. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/py.typed +0 -0
  49. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  50. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/registration.py +0 -0
  51. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/runner_limit.py +0 -0
  52. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/runtime/__init__.py +0 -0
  53. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
  54. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/sealed_box.py +0 -0
  55. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/skill.py +0 -0
  56. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
  57. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/testing/__init__.py +0 -0
  58. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/testing/__main__.py +0 -0
  59. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/testing/plugin.py +0 -0
  60. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/testing/test_conformance.py +0 -0
  61. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/testing/workflow.py +0 -0
  62. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/tiny_http.py +0 -0
  63. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/triage_activities.py +0 -0
  64. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/usage_windows.py +0 -0
  65. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/user_sources.py +0 -0
  66. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/__init__.py +0 -0
  67. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
  68. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/acp_runtime.py +0 -0
  69. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
  70. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
  71. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  72. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
  73. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/command_policy.py +0 -0
  74. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
  75. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/harness_logins.py +0 -0
  76. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/harness_outcome.py +0 -0
  77. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  78. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  79. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/src/agentic_runner/workers/settings.py +0 -0
  80. {agentic_runner-3.25.1 → agentic_runner-3.26.1}/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.1
3
+ Version: 3.26.1
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.12
8
- Requires-Dist: agentic-runner-contracts==3.25.1
8
+ Requires-Dist: agentic-runner-contracts==3.26.1
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.1",
13
+ "agentic-runner-contracts==3.26.1",
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.1"
10
+ __version__ = "3.26.1"
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 (
@@ -556,6 +562,11 @@ class RunnerRalphFastApiClient(Protocol):
556
562
  ) -> dict[str, Any]:
557
563
  """Read one Directive's metered token aggregate (PRD issue 13)."""
558
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
+
559
570
  async def report_harness_usage(self, payload: Mapping[str, Any]) -> list[dict[str, Any]]:
560
571
  """One Usage Record per model off a harness's own JSON (PRD issue 31, 17 A9)."""
561
572
 
@@ -1725,8 +1736,12 @@ class RunnerRalphActivities:
1725
1736
  usage_windows: UsageWindows | None = None,
1726
1737
  runner_limit: AppliedRunnerLimit | None = None,
1727
1738
  llm_contracts: Callable[[], Sequence[ContractLlmTerms] | None] | None = None,
1739
+ llm_keys: LlmKeyOpener | None = None,
1728
1740
  ) -> None:
1729
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
1730
1745
  # What this Runner's host denies every Agent on it, checked at start (ADR-0022
1731
1746
  # §52-§55). Empty by default, which narrows nothing.
1732
1747
  self._runner_limit = runner_limit or EMPTY_RUNNER_LIMIT
@@ -2667,12 +2682,18 @@ class RunnerRalphActivities:
2667
2682
 
2668
2683
  access = routing.llm_access if routing is not None else None
2669
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
+ )
2670
2693
  await self._report_login_residue(work_record_id, state)
2671
2694
  facts = AttemptFacts(
2672
2695
  work_record_id=work_record_id,
2673
- directive_id=directive_id(
2674
- work_record_id=work_record_id, directive_number=directive_number
2675
- ),
2696
+ directive_id=attempt_directive_id,
2676
2697
  contract_id=contract_path_segment(state.contract_id),
2677
2698
  agent_id=state.agent_id or "",
2678
2699
  persona=state.persona_slug or "",
@@ -2731,7 +2752,8 @@ class RunnerRalphActivities:
2731
2752
  # API-key mode only (issue 43): a subscription Directive never has a slot
2732
2753
  # to resolve, and issue 31 meters it from the harness's own output. One
2733
2754
  # bearer serves the callback socket and the proxy alike. The attempt spends
2734
- # 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.
2735
2757
  proxy_attempt = await stack.enter_async_context(
2736
2758
  self._llm_proxy.attempt(
2737
2759
  directive_id=facts.directive_id,
@@ -2745,6 +2767,7 @@ class RunnerRalphActivities:
2745
2767
  access is not None and access.kind == LlmAccessKind.HOST_KEY
2746
2768
  ),
2747
2769
  delivered_only=access is None and not self._personal_runner(),
2770
+ llm_key=llm_key,
2748
2771
  )
2749
2772
  )
2750
2773
  callback_env.update(proxy_attempt.env(state.cli_kind))
@@ -3158,6 +3181,7 @@ class RunnerRalphActivities:
3158
3181
  subscription=self._subscription_state(state),
3159
3182
  subscription_right=self._subscription_right(state),
3160
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,
3161
3185
  )
3162
3186
  return choose_auth_mode(
3163
3187
  host_party=host_party,
@@ -3171,25 +3195,87 @@ class RunnerRalphActivities:
3171
3195
  ),
3172
3196
  )
3173
3197
  except AuthModeRefusedError as refusal:
3174
- await _raise_recorded(
3175
- ApplicationError(str(refusal), type=refusal.rule, non_retryable=True),
3176
- 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(
3177
3221
  work_record_id,
3178
- source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3179
- actor=self._actor(),
3180
- payload={
3181
- "event": (
3182
- "directive.auth_mode_refused"
3183
- if access is None
3184
- else "directive.llm_access_refused"
3185
- ),
3186
- "rule": refusal.rule,
3187
- "cli_kind": state.cli_kind,
3188
- "host_party": host_party,
3189
- "contract_id": state.contract_id,
3190
- **({} if access is None else {"llm_access": access.kind}),
3191
- },
3222
+ access.llm_key_id,
3223
+ LlmKeyReleaseRequest(
3224
+ directive_id=attempt_directive_id, pool_key_fingerprint=fingerprint
3225
+ ).model_dump(mode="json"),
3192
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
+ )
3193
3279
 
3194
3280
  def _runs_under_contract_uid(self, state: _RuntimeContextState) -> bool:
3195
3281
  """Whether this Directive spawns as its own Contract's uid. A Work Record with no
@@ -36,7 +36,9 @@ __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",
41
43
  "SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE",
42
44
  "SUBSCRIPTION_NOT_SIGNED_IN_RULE",
@@ -68,7 +70,12 @@ SUBSCRIPTION_NOT_SIGNED_IN_RULE: Final[str] = "subscription_not_signed_in"
68
70
  SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE: Final[str] = "subscription_off_personal_runner"
69
71
  SUBSCRIPTION_NO_RIGHT_RULE: Final[str] = "subscription_no_right"
70
72
  SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE: Final[str] = "subscription_not_acknowledged"
71
- LLM_KEY_UNSUPPORTED_RULE: Final[str] = "llm_key_unsupported"
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"
72
79
  LLM_ACCESS_UNKNOWN_RULE: Final[str] = "llm_access_unknown"
73
80
  # `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
74
81
  # `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
@@ -154,13 +161,16 @@ def require_named_access(
154
161
  subscription: SubscriptionState,
155
162
  subscription_right: SubscriptionRight | None,
156
163
  runtime_modes: Collection[AuthMode],
164
+ pool_key_held: bool,
157
165
  ) -> AuthMode:
158
166
  """The mode the named access runs in, or a refusal naming what is missing.
159
167
 
160
168
  ``host_key_held`` is the provider's key in this Runner's own store, serving in the
161
- proxy; ``subscription`` the Contract's harness root for the Directive's kind. A
162
- subscription runs only on its person's own Runner (rev 3: never on a pool Runner),
163
- 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.
164
174
 
165
175
  ``subscription_right`` is what the last heartbeat ack said of the Contract's own
166
176
  subscription (``llm_contracts``), so a misrouted Directive is refused here as well as
@@ -210,11 +220,14 @@ def require_named_access(
210
220
  )
211
221
  return AuthMode.SUBSCRIPTION
212
222
  if access.kind == LlmAccessKind.LLM_KEY:
213
- raise AuthModeRefusedError(
214
- LLM_KEY_UNSUPPORTED_RULE,
215
- "this Directive was routed to one of the pool's LLM Keys, which this Runner "
216
- "release cannot open; update the Runner",
217
- )
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
218
231
  raise AuthModeRefusedError(
219
232
  LLM_ACCESS_UNKNOWN_RULE, f"this Runner does not know the LLM access {access.kind!r}"
220
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")
@@ -85,6 +85,14 @@ from agentic_runner.llm_proxy import (
85
85
  from agentic_runner.mcp import ToolServerHealthLog
86
86
  from agentic_runner.message_store import MESSAGES_DIR, MessageStore, TemporalWorkflowSignaller
87
87
  from agentic_runner.oauth_connectors import OAuthConnectors
88
+ from agentic_runner.pool_key import (
89
+ POOL_KEY_ENV,
90
+ POOL_KEY_FILENAME,
91
+ LlmKeyOpener,
92
+ PoolKey,
93
+ PoolKeyError,
94
+ load_pool_key,
95
+ )
88
96
  from agentic_runner.private_state import (
89
97
  UnsafeStateError,
90
98
  ensure_private_dir,
@@ -160,6 +168,7 @@ from agentic_runner_contracts.runner_registration import (
160
168
  HostAttestation,
161
169
  IsolationMode,
162
170
  LifecycleKind,
171
+ PoolTerms,
163
172
  ProviderKeyReport,
164
173
  RecipientKey,
165
174
  ResourcePressure,
@@ -258,6 +267,7 @@ _PRIVATE_STATE_FILES = (
258
267
  LIFECYCLE_FILENAME,
259
268
  UID_MAP_FILENAME,
260
269
  LLM_CONTRACTS_FILENAME,
270
+ POOL_KEY_FILENAME,
261
271
  )
262
272
 
263
273
 
@@ -819,6 +829,10 @@ class ControlPlaneStream:
819
829
  # kept for `agentic-runner sign-in`.
820
830
  install: RunnerInstall | None = None
821
831
  state_dir: Path | None = None
832
+ # Rev 3: the fingerprint of the Pool Key this Runner was given; None when it was given
833
+ # none. Reported only once an ack named the pool (`_pool`), which a control plane on
834
+ # 3.26 or later does.
835
+ pool_key_fingerprint: str | None = None
822
836
  # Console-v2 issue 30: the OAuth Connectors whose code, tokens and verifier stay here.
823
837
  oauth: OAuthConnectors | None = None
824
838
  # ADR-0022 §57: the Runner Limit read at start and its posture; None while it is empty.
@@ -834,6 +848,7 @@ class ControlPlaneStream:
834
848
  # Rev 3: the Contracts the last ack said this Runner serves; None until an ack from a
835
849
  # control plane on 3.25 named them, and the LLM access report is sent only after.
836
850
  _llm_contracts: list[ContractLlmTerms] | None = None
851
+ _pool: PoolTerms | None = None
837
852
  _last_seen: tuple[float, datetime] | None = None
838
853
 
839
854
  def _observe_sleep(self, now: datetime) -> None:
@@ -1023,6 +1038,7 @@ class ControlPlaneStream:
1023
1038
  runner_limit=self.runner_limit,
1024
1039
  llm_access=self._llm_access_report(),
1025
1040
  install=self.install if self._llm_contracts is not None else None,
1041
+ pool_key_fingerprint=self.pool_key_fingerprint if self._pool is not None else None,
1026
1042
  )
1027
1043
  ack = await self.client.heartbeat(self.state, envelope)
1028
1044
  if self.sources is not None:
@@ -1048,6 +1064,7 @@ class ControlPlaneStream:
1048
1064
  self._sync_delivered()
1049
1065
  self._manifests = list(ack.credential_manifests)
1050
1066
  self._take_llm_contracts(ack.llm_contracts)
1067
+ self._take_pool(ack.pool)
1051
1068
  await self._sync_llm_slots()
1052
1069
  if ack.runner_token is not None:
1053
1070
  self.runner_token = ack.runner_token
@@ -1106,6 +1123,25 @@ class ControlPlaneStream:
1106
1123
  if self.state_dir is not None:
1107
1124
  save_llm_contracts(self.state_dir, terms)
1108
1125
 
1126
+ def _take_pool(self, pool: PoolTerms | None) -> None:
1127
+ """Keep the ack's pool. An ack without one (a personal Runner, or a control plane
1128
+ below 3.26) leaves what is held."""
1129
+
1130
+ if pool is None or pool == self._pool:
1131
+ return
1132
+ self._pool = pool
1133
+ current = pool.pool_key_fingerprint
1134
+ if current is not None and current != self.pool_key_fingerprint:
1135
+ # The operator's signal: this Runner opens none of the pool's LLM Keys until
1136
+ # its install carries the Pool Key now on the Runners page.
1137
+ _logger.warning(
1138
+ "this Runner holds Pool Key %s, not the pool's current %s; it opens none of "
1139
+ "the pool's LLM Keys until %s is updated",
1140
+ self.pool_key_fingerprint or "none",
1141
+ current,
1142
+ POOL_KEY_ENV,
1143
+ )
1144
+
1109
1145
  async def _sync_host_keys(self) -> None:
1110
1146
  """The host store's keys, by the provider's key name, become the proxy's host slots
1111
1147
  (Spec: Runners rev 3), serving every Contract this Runner runs.
@@ -1367,6 +1403,13 @@ async def _run(
1367
1403
  print(f"refusing to start (runner_limit): {error}", flush=True)
1368
1404
  return 1
1369
1405
  state_dir = config.state_dir
1406
+ try:
1407
+ pool_key = load_pool_key(state_dir)
1408
+ except PoolKeyError as error:
1409
+ # Refused rather than started without it: the pool's LLM Keys would then fail
1410
+ # every Directive routed to them, far from the typo that caused it.
1411
+ print(f"refusing to start (pool_key_invalid): {error}", flush=True)
1412
+ return 1
1370
1413
 
1371
1414
  readiness = readiness or Readiness(int(os.environ.get(READINESS_PORT_ENV, "").strip() or 0))
1372
1415
  await readiness.start()
@@ -1388,6 +1431,7 @@ async def _run(
1388
1431
  attestation=attestation,
1389
1432
  install=install,
1390
1433
  runner_limit=runner_limit,
1434
+ pool_key=pool_key,
1391
1435
  )
1392
1436
  finally:
1393
1437
  await readiness.close()
@@ -1411,6 +1455,7 @@ async def _serve(
1411
1455
  attestation: HostAttestation | None,
1412
1456
  install: RunnerInstall | None = None,
1413
1457
  runner_limit: AppliedRunnerLimit = EMPTY_RUNNER_LIMIT,
1458
+ pool_key: PoolKey | None = None,
1414
1459
  ) -> int:
1415
1460
  limit_report = runner_limit_report(
1416
1461
  runner_limit,
@@ -1480,6 +1525,7 @@ async def _serve(
1480
1525
  install=install,
1481
1526
  lifecycle=lifecycle,
1482
1527
  runner_limit=runner_limit,
1528
+ pool_key=pool_key,
1483
1529
  )
1484
1530
  except _StoppedBeforeStartError:
1485
1531
  # No beat with a 10 s timeout on the way out (`_report_stop`): the control plane
@@ -1512,6 +1558,7 @@ async def _serve_registered(
1512
1558
  lifecycle: LifecycleOutbox,
1513
1559
  install: RunnerInstall | None = None,
1514
1560
  runner_limit: AppliedRunnerLimit = EMPTY_RUNNER_LIMIT,
1561
+ pool_key: PoolKey | None = None,
1515
1562
  ) -> int:
1516
1563
  load = _LoadInterceptor()
1517
1564
  proxy = LlmProxy(slots=SlotStore(), ceilings=CeilingStore(), outbox=UsageOutbox())
@@ -1535,23 +1582,28 @@ async def _serve_registered(
1535
1582
  usage_windows = build_usage_windows(
1536
1583
  host_party=state.host_party, runtimes=runtimes, isolation=isolation, in_flight=in_flight
1537
1584
  )
1585
+ # One store for both: it caches the key file, and a renewal through the stream must
1586
+ # be the key the LLM Key opener reads too.
1587
+ recipient_keys = RecipientKeyStore(state_dir)
1588
+ llm_slots = llm_slot_references(
1589
+ {
1590
+ name: url
1591
+ for name, url in (
1592
+ ("openai", config.openai_base_url),
1593
+ ("anthropic", config.anthropic_base_url),
1594
+ )
1595
+ if url
1596
+ }
1597
+ )
1598
+ llm_keys = LlmKeyOpener(pool_key=pool_key, recipient_keys=recipient_keys, endpoints=llm_slots)
1538
1599
  stream = ControlPlaneStream(
1539
1600
  client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
1540
1601
  state=state,
1541
1602
  isolation=mode,
1542
1603
  proxy=proxy,
1543
- sealed=SealedCredentialStream(RecipientKeyStore(state_dir)),
1604
+ sealed=SealedCredentialStream(recipient_keys),
1544
1605
  credentials=credentials,
1545
- llm_slots=llm_slot_references(
1546
- {
1547
- name: url
1548
- for name, url in (
1549
- ("openai", config.openai_base_url),
1550
- ("anthropic", config.anthropic_base_url),
1551
- )
1552
- if url
1553
- }
1554
- ),
1606
+ llm_slots=llm_slots,
1555
1607
  load=load,
1556
1608
  egress_posture=egress_posture,
1557
1609
  attestation=attestation,
@@ -1564,6 +1616,7 @@ async def _serve_registered(
1564
1616
  runner_limit=runner_limit_report(runner_limit, runtimes, egress_posture=egress_posture),
1565
1617
  install=install,
1566
1618
  state_dir=state_dir,
1619
+ pool_key_fingerprint=llm_keys.fingerprint,
1567
1620
  )
1568
1621
  stream.sources = UserSourcePoller(
1569
1622
  stream=SignedIntakeStream(stream.client, state),
@@ -1631,6 +1684,7 @@ async def _serve_registered(
1631
1684
  usage_windows=usage_windows,
1632
1685
  runner_limit=runner_limit,
1633
1686
  llm_contracts=lambda: stream.llm_contracts,
1687
+ llm_keys=llm_keys,
1634
1688
  # The wake signal rides the connection this process polls with: one
1635
1689
  # namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
1636
1690
  signaller=TemporalWorkflowSignaller(client),
@@ -1829,6 +1883,7 @@ def _activities(
1829
1883
  usage_windows: UsageWindows | None = None,
1830
1884
  runner_limit: AppliedRunnerLimit | None = None,
1831
1885
  llm_contracts: Callable[[], list[ContractLlmTerms] | None] | None = None,
1886
+ llm_keys: LlmKeyOpener | None = None,
1832
1887
  ) -> list[Callable[..., Any]]:
1833
1888
  github_client = GitHubAppClient(
1834
1889
  app_id=os.getenv("GITHUB_APP_ID"),
@@ -1879,6 +1934,7 @@ def _activities(
1879
1934
  usage_windows=usage_windows,
1880
1935
  runner_limit=runner_limit,
1881
1936
  llm_contracts=llm_contracts,
1937
+ llm_keys=llm_keys,
1882
1938
  )
1883
1939
  return [
1884
1940
  *ralph.activity_callables(),
@@ -14,9 +14,10 @@ personal Runner serves one Contract, which the last heartbeat ack named
14
14
  ``codex login --device-auth`` with the code shown here. No code or token passes through
15
15
  the control plane.
16
16
 
17
- It refuses on a pool Runner, which uses only its pool's keys. It refuses a subscription
18
- when the ack says the Contract lacks the right or the person has not acknowledged, and
19
- says which.
17
+ It refuses on a pool Runner, which uses only its pool's keys. It offers a subscription
18
+ only when the ack says the Contract allows it; otherwise it says which of the right or
19
+ the person's acknowledgment is missing, names the console page that turns it on, and
20
+ offers the key (QTS-1776).
20
21
  """
21
22
 
22
23
  from __future__ import annotations
@@ -92,14 +93,21 @@ class SignIn:
92
93
 
93
94
  async def _one(self, cli_kind: str) -> int:
94
95
  label = _LABELS[cli_kind]
95
- answer = self.ask(
96
- f"{label}: sign in with your [s]ubscription, store an API [k]ey, or skip? [s/k/N] "
97
- )
96
+ offer = self._subscription_offer()
97
+ if isinstance(offer, str):
98
+ # Offering `[s]` and then refusing it is what sent an Organisation Admin looking
99
+ # for a switch nothing named (QTS-1776).
100
+ self.say(f"{label}: {offer}")
101
+ answer = self.ask(f"{label}: store an API [k]ey, or skip? [k/N] ")
102
+ else:
103
+ answer = self.ask(
104
+ f"{label}: sign in with your [s]ubscription, store an API [k]ey, or skip? [s/k/N] "
105
+ )
98
106
  choice = answer.strip().lower()[:1]
99
107
  if choice == "k":
100
108
  return self._key(cli_kind)
101
- if choice == "s":
102
- return await self._subscription(cli_kind)
109
+ if choice == "s" and not isinstance(offer, str):
110
+ return await self._subscription(offer, cli_kind)
103
111
  self.say(f"{label}: skipped")
104
112
  return 0
105
113
 
@@ -118,39 +126,38 @@ class SignIn:
118
126
  )
119
127
  return 0
120
128
 
121
- async def _subscription(self, cli_kind: str) -> int:
122
- contract = self._contract()
123
- if contract is None:
124
- return 1
125
- if contract.subscription == SubscriptionRight.NO_RIGHT:
126
- self.say(
127
- "refusing a subscription: your Contract does not allow your own subscription. "
128
- "Ask an Organisation Admin to turn it on, or store an API key instead"
129
- )
130
- return 1
131
- if contract.subscription == SubscriptionRight.NOT_ACKNOWLEDGED:
132
- self.say(
133
- "refusing a subscription: you have not acknowledged the subscription terms. "
134
- "Acknowledge them on your Contract in the console, then run this again"
135
- )
136
- return 1
129
+ async def _subscription(self, contract: ContractLlmTerms, cli_kind: str) -> int:
137
130
  returncode = await self.subscription(str(contract.contract_id), cli_kind)
138
131
  if returncode != 0:
139
132
  self.say(f"{_LABELS[cli_kind]}: the sign-in exited {returncode}; nothing changed")
140
133
  return returncode
141
134
 
142
- def _contract(self) -> ContractLlmTerms | None:
135
+ def _subscription_offer(self) -> ContractLlmTerms | str:
136
+ """The one Contract a subscription signs in, or why the ack offers none."""
137
+
143
138
  if self.contracts is None:
144
- self.say(
145
- "refusing a subscription: this Runner has not yet been told its Contract. "
146
- "Start it, wait for its first heartbeat, and run this again (the control "
147
- "plane must be on contracts 3.25 or later)"
139
+ return (
140
+ "no subscription yet: this Runner has not yet been told its Contract. Start "
141
+ "it, wait for its first heartbeat, and run this again (the control plane must "
142
+ "be on contracts 3.25 or later)"
148
143
  )
149
- return None
150
144
  if len(self.contracts) != 1:
151
- self.say(
152
- f"refusing a subscription: a personal Runner serves one Contract, and the "
153
- f"control plane named {len(self.contracts)}"
145
+ return (
146
+ "no subscription: a personal Runner serves one Contract, and the control "
147
+ f"plane named {len(self.contracts)}"
148
+ )
149
+ contract = self.contracts[0]
150
+ if contract.subscription == SubscriptionRight.NO_RIGHT:
151
+ # TODO(QTS-1744): name the Contract editor once the right moves onto the Contract.
152
+ return (
153
+ "your own subscription is off for this Runner. An Organisation Admin turns it "
154
+ "on in the console with Allow subscription sign-in, on this Runner under "
155
+ "Personal Runners on /orgs/<organisation>/runners"
156
+ )
157
+ if contract.subscription == SubscriptionRight.NOT_ACKNOWLEDGED:
158
+ return (
159
+ "your own subscription is on for this Runner, and you have not acknowledged "
160
+ "the subscription terms. Acknowledge them in the console on My Contracts "
161
+ "(/me/contracts), then run this again"
154
162
  )
155
- return None
156
- return self.contracts[0]
163
+ return contract
@@ -26,6 +26,9 @@ Contracts 3.25 (Spec: Runners rev 3): every ack names the one Contract this fake
26
26
  ``llm_contracts``, with ``subscription`` as its right, so a Runner reports the LLM access it
27
27
  holds for it; :attr:`FakeControlPlane.llm_access` keeps each report. ``subscription``
28
28
  ``None`` answers as a control plane below 3.25 does: no Contract terms at all.
29
+
30
+ Contracts 3.26: :attr:`FakeControlPlane.pool` is the ack's pool and its current Pool Key;
31
+ None, the default, answers for a personal Runner or a control plane below 3.26.
29
32
  """
30
33
 
31
34
  from __future__ import annotations
@@ -69,6 +72,7 @@ from agentic_runner_contracts.runner_registration import (
69
72
  FloorState,
70
73
  HeartbeatAck,
71
74
  HeartbeatEnvelope,
75
+ PoolTerms,
72
76
  RunnerIdentityMaterial,
73
77
  SubscriptionRight,
74
78
  )
@@ -179,6 +183,7 @@ class FakeControlPlane:
179
183
  # The ack's word on whether the person may sign this Contract in with their own
180
184
  # subscription; None leaves `llm_contracts` out, as a control plane below 3.25 does.
181
185
  subscription: SubscriptionRight | None = SubscriptionRight.NO_RIGHT
186
+ pool: PoolTerms | None = None
182
187
 
183
188
  bootstraps: list[BootstrapRequest] = field(default_factory=list)
184
189
  runner_ids: list[UUID] = field(default_factory=list)
@@ -459,6 +464,7 @@ class FakeControlPlane:
459
464
  ContractLlmTerms(contract_id=self.contract_id, subscription=self.subscription)
460
465
  ]
461
466
  ),
467
+ pool=self.pool,
462
468
  )
463
469
  return _Answer(200, ack.model_dump(mode="json"))
464
470
 
@@ -176,6 +176,20 @@ class RunnerFastApiClient:
176
176
  records = result.get("records")
177
177
  return records if isinstance(records, list) else []
178
178
 
179
+ async def release_llm_key(
180
+ self, work_record_id: str, llm_key_id: str, payload: Mapping[str, Any]
181
+ ) -> dict[str, Any]:
182
+ """The two sealed shares of the LLM Key a Directive's ``llm_access`` names (Spec:
183
+ Runners rev 3). ``payload`` is ``LlmKeyReleaseRequest``'s shape; the Directive
184
+ token says which Runner and Directive ask, which is what the control plane releases
185
+ against."""
186
+
187
+ return await self._post(
188
+ f"{self._prefix}/work-records/{_path_segment(work_record_id)}"
189
+ f"/llm-keys/{_path_segment(llm_key_id)}/release",
190
+ payload,
191
+ )
192
+
179
193
  async def append_evidence(
180
194
  self,
181
195
  work_record_id: str,
@@ -49,6 +49,7 @@ from agentic_runner.cli_floor import CLI_FLOORS, CLI_NAMES, meets_floor, parse_c
49
49
  from agentic_runner.config import CONFIG_FILE_ENV
50
50
  from agentic_runner.host_store import open_workstation_store
51
51
  from agentic_runner.lifecycle import LifecycleOutbox
52
+ from agentic_runner.pool_key import parse_pool_key, save_pool_key
52
53
  from agentic_runner.registration import load_state
53
54
  from agentic_runner.service import HEARTBEAT_STAMP_NAME, PIDFILE_NAME
54
55
  from agentic_runner.workers.acp_runtime import ACP_BRIDGES
@@ -637,6 +638,7 @@ async def install(
637
638
  settings: WorkstationSettings,
638
639
  *,
639
640
  registration_token: str,
641
+ pool_key: str | None = None,
640
642
  platform: str = sys.platform,
641
643
  home: Path | None = None,
642
644
  start_service: bool = True,
@@ -650,9 +652,16 @@ async def install(
650
652
  prompt, never stored: the identity bootstrap hands back is what every later start uses,
651
653
  and a lost state directory means a reinstall and a re-delivery of any sealed value
652
654
  (22 A4), by design.
655
+
656
+ ``pool_key`` is the Organisation's Pool Key, for a Runner of its pool that opens the
657
+ pool's LLM Keys (Spec: Runners rev 3). Unlike the token it is kept, 0600 in the state
658
+ directory, because every start of the Runner needs it.
653
659
  """
654
660
 
655
661
  require_any_cli(settings.path)
662
+ if pool_key:
663
+ # Checked before anything registers; kept only once something has.
664
+ parse_pool_key(pool_key)
656
665
  paths.create()
657
666
  settings.save(paths)
658
667
  already_registered = load_state(paths.state_dir) is not None
@@ -664,6 +673,8 @@ async def install(
664
673
  registration_token=registration_token,
665
674
  isolation=IsolationMode.NONE,
666
675
  )
676
+ if pool_key:
677
+ save_pool_key(paths.state_dir, pool_key)
667
678
  LifecycleOutbox(paths.state_dir).add(LifecycleKind.INSTALL, datetime.now(UTC))
668
679
  definition = service_definition(paths, settings, platform=platform, home=home)
669
680
  definition.path.parent.mkdir(parents=True, exist_ok=True)
File without changes