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.
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/PKG-INFO +2 -2
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/pyproject.toml +1 -1
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/activities.py +131 -23
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/auth_mode.py +48 -11
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/cli.py +25 -2
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/llm_proxy.py +7 -1
- agentic_runner-3.26.0/src/agentic_runner/pool_key.py +175 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/service.py +76 -11
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/control_plane.py +6 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/triage_activities.py +19 -2
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/fastapi_client.py +14 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workstation.py +11 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/.gitignore +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/LICENSE +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/README.md +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/launch.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/llm_contracts.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/oauth_connectors.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/runner_limit.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/sign_in.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/skill.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/usage_windows.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/harness_logins.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.25.0 → agentic_runner-3.26.0}/src/agentic_runner/workers/settings.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
|
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=
|
|
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,
|
|
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
|
|
3166
|
-
|
|
3167
|
-
|
|
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
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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 (
|
|
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")
|