agentic-runner 3.24.0__tar.gz → 3.25.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 (81) hide show
  1. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/PKG-INFO +2 -2
  2. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/pyproject.toml +1 -1
  3. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/activities.py +94 -15
  5. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/auth_mode.py +98 -0
  6. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/cli.py +92 -18
  7. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/credentials.py +27 -36
  8. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/heartbeat_link.py +0 -1
  9. agentic_runner-3.25.1/src/agentic_runner/llm_contracts.py +41 -0
  10. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/llm_proxy.py +168 -81
  11. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/service.py +282 -139
  12. agentic_runner-3.25.1/src/agentic_runner/sign_in.py +156 -0
  13. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +23 -26
  14. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/testing/control_plane.py +23 -0
  15. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/testing/test_conformance.py +8 -0
  16. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/triage_activities.py +19 -2
  17. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/claude_sign_in.py +12 -6
  18. agentic_runner-3.25.1/src/agentic_runner/workers/harness_logins.py +172 -0
  19. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workstation.py +5 -1
  20. agentic_runner-3.24.0/src/agentic_runner/device_login_activities.py +0 -119
  21. agentic_runner-3.24.0/src/agentic_runner/workers/contract_device_login.py +0 -241
  22. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/.gitignore +0 -0
  23. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/LICENSE +0 -0
  24. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/README.md +0 -0
  25. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/attempts.py +0 -0
  26. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/build.py +0 -0
  27. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/callback.py +0 -0
  28. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/child_watcher.py +0 -0
  29. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/cli_floor.py +0 -0
  30. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/config.py +0 -0
  31. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/egress.py +0 -0
  32. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/harness_self_test.py +0 -0
  33. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/hooks.py +0 -0
  34. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/host_store.py +0 -0
  35. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/__init__.py +0 -0
  36. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  37. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  38. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  39. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  40. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  41. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  42. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  43. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  44. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  45. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  46. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  47. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/launch.py +0 -0
  48. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/lifecycle.py +0 -0
  49. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/mcp.py +0 -0
  50. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/message_store.py +0 -0
  51. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/oauth_connectors.py +0 -0
  52. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/private_state.py +0 -0
  53. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/py.typed +0 -0
  54. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  55. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/registration.py +0 -0
  56. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/runner_limit.py +0 -0
  57. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/runtime/__init__.py +0 -0
  58. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
  59. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/sealed_box.py +0 -0
  60. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/skill.py +0 -0
  61. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/testing/__init__.py +0 -0
  62. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/testing/__main__.py +0 -0
  63. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/testing/plugin.py +0 -0
  64. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/testing/workflow.py +0 -0
  65. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/tiny_http.py +0 -0
  66. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/usage_windows.py +0 -0
  67. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/user_sources.py +0 -0
  68. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/__init__.py +0 -0
  69. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
  70. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/acp_runtime.py +0 -0
  71. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
  72. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
  73. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
  74. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/command_policy.py +0 -0
  75. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
  76. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
  77. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/harness_outcome.py +0 -0
  78. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  79. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  80. {agentic_runner-3.24.0 → agentic_runner-3.25.1}/src/agentic_runner/workers/settings.py +0 -0
  81. {agentic_runner-3.24.0 → agentic_runner-3.25.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.24.0
3
+ Version: 3.25.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.24.0
8
+ Requires-Dist: agentic-runner-contracts==3.25.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.24.0",
13
+ "agentic-runner-contracts==3.25.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.24.0"
10
+ __version__ = "3.25.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -49,6 +49,7 @@ from agentic_runner.auth_mode import (
49
49
  AuthModeRefusedError,
50
50
  choose_auth_mode,
51
51
  is_shared_runner,
52
+ require_named_access,
52
53
  )
53
54
  from agentic_runner.callback import (
54
55
  CALLBACK_TOKEN_ENV,
@@ -149,7 +150,6 @@ from agentic_runner.workers.agent_runtime import (
149
150
  PermissionFallback,
150
151
  ResumableAgentRuntime,
151
152
  )
152
- from agentic_runner.workers.contract_device_login import holds_login, login_file_counts
153
153
  from agentic_runner.workers.contract_isolation import (
154
154
  NO_CONTRACT,
155
155
  ContractIsolation,
@@ -161,6 +161,11 @@ from agentic_runner.workers.fastapi_client import (
161
161
  WorkerFastApiClientError,
162
162
  directive_token,
163
163
  )
164
+ from agentic_runner.workers.harness_logins import (
165
+ holds_login,
166
+ login_file_counts,
167
+ subscription_report,
168
+ )
164
169
  from agentic_runner.workers.harness_outcome import classify_harness_outcome
165
170
  from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
166
171
  from agentic_runner.workers.mcp_config import McpServerEntry
@@ -277,6 +282,7 @@ from agentic_runner_contracts.grants import (
277
282
  GrantSnapshot,
278
283
  )
279
284
  from agentic_runner_contracts.grants.seam import VerbDecision, decide_verb
285
+ from agentic_runner_contracts.llm_access import LlmAccess, LlmAccessKind, provider_for
280
286
  from agentic_runner_contracts.questions import WORK_ASK_VERB
281
287
  from agentic_runner_contracts.redaction import redact_secret_like_text
282
288
  from agentic_runner_contracts.routing import (
@@ -286,6 +292,11 @@ from agentic_runner_contracts.routing import (
286
292
  assert_routed,
287
293
  selector_satisfied,
288
294
  )
295
+ from agentic_runner_contracts.runner_registration import (
296
+ ContractLlmTerms,
297
+ SubscriptionRight,
298
+ SubscriptionState,
299
+ )
289
300
  from agentic_runner_contracts.runtime_context import (
290
301
  EGRESS_ALLOW_LIST_KEY,
291
302
  McpServerSpec,
@@ -1713,6 +1724,7 @@ class RunnerRalphActivities:
1713
1724
  directives_in_flight: DirectivesInFlight | None = None,
1714
1725
  usage_windows: UsageWindows | None = None,
1715
1726
  runner_limit: AppliedRunnerLimit | None = None,
1727
+ llm_contracts: Callable[[], Sequence[ContractLlmTerms] | None] | None = None,
1716
1728
  ) -> None:
1717
1729
  self._fastapi_client = fastapi_client
1718
1730
  # What this Runner's host denies every Agent on it, checked at start (ADR-0022
@@ -1787,6 +1799,9 @@ class RunnerRalphActivities:
1787
1799
  # The retry fence (runner-repo 05). None fences nothing: a process that never
1788
1800
  # registered has no state directory to keep the records in.
1789
1801
  self._attempt_records = attempt_records
1802
+ # The Contracts the last heartbeat ack named, read at each Directive (QTS-1751).
1803
+ # None, or a callable answering None, before any ack from a 3.25 control plane.
1804
+ self._llm_contracts = llm_contracts
1790
1805
 
1791
1806
  def activity_callables(self) -> list[Callable[..., Any]]:
1792
1807
  """Return decorated activity callables for Temporal worker registration."""
@@ -2650,7 +2665,8 @@ class RunnerRalphActivities:
2650
2665
  the bearer defensible under ADR-0011 §9.
2651
2666
  """
2652
2667
 
2653
- auth_mode = await self._directive_auth_mode(work_record_id, state)
2668
+ access = routing.llm_access if routing is not None else None
2669
+ auth_mode = await self._directive_auth_mode(work_record_id, state, access)
2654
2670
  await self._report_login_residue(work_record_id, state)
2655
2671
  facts = AttemptFacts(
2656
2672
  work_record_id=work_record_id,
@@ -2669,9 +2685,7 @@ class RunnerRalphActivities:
2669
2685
  )
2670
2686
 
2671
2687
  await self._refuse_skill_digest_mismatch(work_record_id, state)
2672
- credentials = await self._resolve_credentials(
2673
- work_record_id, state, funding_party=routing.funding_party if routing else None
2674
- )
2688
+ credentials = await self._resolve_credentials(work_record_id, state)
2675
2689
  if credentials is not None:
2676
2690
  # Names only, at the moment they resolved (22 A1's Evidence): a support read
2677
2691
  # of "which credentials did this Directive have" must not be answerable by
@@ -2716,7 +2730,8 @@ class RunnerRalphActivities:
2716
2730
  if self._llm_proxy is not None and auth_mode == AuthMode.API_KEY:
2717
2731
  # API-key mode only (issue 43): a subscription Directive never has a slot
2718
2732
  # to resolve, and issue 31 meters it from the harness's own output. One
2719
- # bearer serves the callback socket and the proxy alike.
2733
+ # 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.
2720
2735
  proxy_attempt = await stack.enter_async_context(
2721
2736
  self._llm_proxy.attempt(
2722
2737
  directive_id=facts.directive_id,
@@ -2725,6 +2740,11 @@ class RunnerRalphActivities:
2725
2740
  work_record_id=_optional_uuid(work_record_id),
2726
2741
  token=callback_env.get(CALLBACK_TOKEN_ENV),
2727
2742
  reserve_max_tokens=reserve_max_tokens,
2743
+ provider=provider_for(state.cli_kind),
2744
+ host_key_only=(
2745
+ access is not None and access.kind == LlmAccessKind.HOST_KEY
2746
+ ),
2747
+ delivered_only=access is None and not self._personal_runner(),
2728
2748
  )
2729
2749
  )
2730
2750
  callback_env.update(proxy_attempt.env(state.cli_kind))
@@ -3081,8 +3101,6 @@ class RunnerRalphActivities:
3081
3101
  self,
3082
3102
  work_record_id: str,
3083
3103
  state: _RuntimeContextState,
3084
- *,
3085
- funding_party: str | None = None,
3086
3104
  ) -> DirectiveCredentials | None:
3087
3105
  """Resolve this Contract's Credential References, or fail the Directive closed.
3088
3106
 
@@ -3100,7 +3118,6 @@ class RunnerRalphActivities:
3100
3118
  return resolver.resolve(
3101
3119
  contract_id=state.contract_id,
3102
3120
  manifest=state.credential_references,
3103
- funding_party=funding_party,
3104
3121
  )
3105
3122
  except UnresolvableCredentialReferenceError as error:
3106
3123
  await self._fastapi_client.append_evidence(
@@ -3111,11 +3128,15 @@ class RunnerRalphActivities:
3111
3128
  raise
3112
3129
 
3113
3130
  async def _directive_auth_mode(
3114
- self, work_record_id: str, state: _RuntimeContextState
3131
+ self, work_record_id: str, state: _RuntimeContextState, access: LlmAccess | None = None
3115
3132
  ) -> AuthMode:
3116
3133
  """This Directive's auth mode, or a non-retryable refusal with Evidence naming the
3117
3134
  rule (local-agents 04).
3118
3135
 
3136
+ ``access`` is the LLM access the control plane routed the Directive with (rev 3):
3137
+ the Runner runs on exactly that, or refuses and never falls back to other access.
3138
+ None, from a control plane below contracts 3.25, keeps the Runner's own choice.
3139
+
3119
3140
  Decided from what this process can check -- the host party it was registered with,
3120
3141
  whether a key is present for the Contract, whether the Contract runs under its own
3121
3142
  uid here and whether its harness root holds a login (QTS-1450) -- never from the
@@ -3129,6 +3150,15 @@ class RunnerRalphActivities:
3129
3150
  host_party = self._routing_identity.host_party if self._routing_identity else None
3130
3151
  contract_uid = self._runs_under_contract_uid(state)
3131
3152
  try:
3153
+ if access is not None:
3154
+ return require_named_access(
3155
+ access,
3156
+ host_party=host_party,
3157
+ host_key_held=self._host_key_held(state),
3158
+ subscription=self._subscription_state(state),
3159
+ subscription_right=self._subscription_right(state),
3160
+ runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
3161
+ )
3132
3162
  return choose_auth_mode(
3133
3163
  host_party=host_party,
3134
3164
  key_present=self._api_key_present(state, runtime),
@@ -3148,11 +3178,16 @@ class RunnerRalphActivities:
3148
3178
  source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3149
3179
  actor=self._actor(),
3150
3180
  payload={
3151
- "event": "directive.auth_mode_refused",
3181
+ "event": (
3182
+ "directive.auth_mode_refused"
3183
+ if access is None
3184
+ else "directive.llm_access_refused"
3185
+ ),
3152
3186
  "rule": refusal.rule,
3153
3187
  "cli_kind": state.cli_kind,
3154
3188
  "host_party": host_party,
3155
3189
  "contract_id": state.contract_id,
3190
+ **({} if access is None else {"llm_access": access.kind}),
3156
3191
  },
3157
3192
  )
3158
3193
 
@@ -3167,16 +3202,60 @@ class RunnerRalphActivities:
3167
3202
  )
3168
3203
 
3169
3204
  def _api_key_present(self, state: _RuntimeContextState, runtime: AgentRuntime | None) -> bool:
3170
- """A delivered slot for the Contract (or its funder), or the host operator's key."""
3205
+ """A slot the attempt's provider would resolve: delivered for the Contract, or the
3206
+ host store's key (rev 3); or a key the host configured on the runtime itself."""
3171
3207
 
3172
3208
  if bool(getattr(runtime, "host_api_key", False)):
3173
3209
  return True
3174
- contract_id = _optional_uuid(state.contract_id)
3210
+ return self._llm_proxy is not None and self._llm_proxy.slots.holds(
3211
+ _optional_uuid(state.contract_id),
3212
+ provider_for(state.cli_kind),
3213
+ delivered_only=not self._personal_runner(),
3214
+ )
3215
+
3216
+ def _personal_runner(self) -> bool:
3217
+ """Hosted by its person. Only such a Runner spends its store's key on a Directive
3218
+ that names no access (a control plane below 3.25): a pool Runner's key never
3219
+ reached the proxy before, and that control plane caps none of its spend."""
3220
+
3221
+ return self._routing_identity is not None and self._routing_identity.host_party == "user"
3222
+
3223
+ def _host_key_held(self, state: _RuntimeContextState) -> bool:
3224
+ provider = provider_for(state.cli_kind)
3175
3225
  return (
3176
3226
  self._llm_proxy is not None
3177
- and contract_id is not None
3178
- and self._llm_proxy.slots.holds(contract_id)
3227
+ and provider is not None
3228
+ and self._llm_proxy.slots.holds_host(provider.value)
3229
+ )
3230
+
3231
+ def _subscription_right(self, state: _RuntimeContextState) -> SubscriptionRight | None:
3232
+ """What the last ack said of this Contract's own subscription; ``no_right`` for a
3233
+ Contract it did not name, since nothing then allowed one here."""
3234
+
3235
+ terms = self._llm_contracts() if self._llm_contracts is not None else None
3236
+ if terms is None:
3237
+ return None
3238
+ contract_id = _optional_uuid(state.contract_id)
3239
+ return next(
3240
+ (term.subscription for term in terms if term.contract_id == contract_id),
3241
+ SubscriptionRight.NO_RIGHT,
3242
+ )
3243
+
3244
+ def _subscription_state(self, state: _RuntimeContextState) -> SubscriptionState:
3245
+ if self._contract_isolation is None or not state.contract_id:
3246
+ return SubscriptionState.NONE
3247
+ windows = (
3248
+ self._usage_windows.windows(state.contract_id, state.cli_kind)
3249
+ if self._usage_windows is not None
3250
+ else None
3179
3251
  )
3252
+ return subscription_report(
3253
+ self._contract_isolation,
3254
+ state.contract_id,
3255
+ state.cli_kind,
3256
+ windows=windows,
3257
+ now=datetime.now(UTC),
3258
+ ).state
3180
3259
 
3181
3260
  async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
3182
3261
  """On a shared Runner that cannot run a subscription, say once that a Contract's
@@ -15,6 +15,12 @@ login is the Contract's alone:
15
15
  The platform decides who may use a subscription at the door (the Subscription Principal
16
16
  and the Organisation's opt-in, QTS-1350); this module is the backstop, so it decides from
17
17
  what the Runner itself can check and never from the payload.
18
+
19
+ From contracts 3.25 the platform names the access in the Directive's routing
20
+ (``DirectiveRouting.llm_access``, Spec: Runners rev 3). :func:`require_named_access` then
21
+ replaces the choice: the Runner runs on exactly the named access or refuses, and never
22
+ falls back to another. A Directive without it, from a control plane on an older pin, keeps
23
+ :func:`choose_auth_mode`.
18
24
  """
19
25
 
20
26
  from __future__ import annotations
@@ -23,16 +29,26 @@ from collections.abc import Collection
23
29
  from typing import Final
24
30
 
25
31
  from agentic_runner.workers.agent_runtime import AuthMode
32
+ from agentic_runner_contracts.llm_access import LlmAccess, LlmAccessKind
33
+ from agentic_runner_contracts.runner_registration import SubscriptionRight, SubscriptionState
26
34
 
27
35
  __all__ = [
28
36
  "NO_RUNTIME_SUBSCRIPTION_RULE",
37
+ "HOST_KEY_NOT_HELD_RULE",
38
+ "LLM_ACCESS_UNKNOWN_RULE",
39
+ "LLM_KEY_UNSUPPORTED_RULE",
29
40
  "SETUP_TOKEN_REFUSED_RULE",
41
+ "SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE",
42
+ "SUBSCRIPTION_NOT_SIGNED_IN_RULE",
43
+ "SUBSCRIPTION_NO_RIGHT_RULE",
44
+ "SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE",
30
45
  "SHARED_HOST_PARTIES",
31
46
  "SIGN_IN_NEEDS_CONTRACT_UID_RULE",
32
47
  "SUBSCRIPTION_NEEDS_CONTRACT_UID_RULE",
33
48
  "SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE",
34
49
  "AuthModeRefusedError",
35
50
  "choose_auth_mode",
51
+ "require_named_access",
36
52
  "is_setup_token",
37
53
  "is_shared_runner",
38
54
  "runs_subscriptions",
@@ -46,6 +62,14 @@ SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE: Final[str] = "subscription_needs_harness_
46
62
  NO_RUNTIME_SUBSCRIPTION_RULE: Final[str] = "runtime_has_no_subscription_mode"
47
63
  SIGN_IN_NEEDS_CONTRACT_UID_RULE: Final[str] = "sign_in_needs_contract_uid"
48
64
  SETUP_TOKEN_REFUSED_RULE: Final[str] = "setup_token_refused"
65
+ # A Directive whose named `llm_access` this Runner no longer holds (rev 3).
66
+ HOST_KEY_NOT_HELD_RULE: Final[str] = "host_key_not_held"
67
+ SUBSCRIPTION_NOT_SIGNED_IN_RULE: Final[str] = "subscription_not_signed_in"
68
+ SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE: Final[str] = "subscription_off_personal_runner"
69
+ SUBSCRIPTION_NO_RIGHT_RULE: Final[str] = "subscription_no_right"
70
+ SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE: Final[str] = "subscription_not_acknowledged"
71
+ LLM_KEY_UNSUPPORTED_RULE: Final[str] = "llm_key_unsupported"
72
+ LLM_ACCESS_UNKNOWN_RULE: Final[str] = "llm_access_unknown"
49
73
  # `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
50
74
  # `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
51
75
  # Runner can refuse on.
@@ -122,5 +146,79 @@ def choose_auth_mode(
122
146
  return AuthMode.SUBSCRIPTION
123
147
 
124
148
 
149
+ def require_named_access(
150
+ access: LlmAccess,
151
+ *,
152
+ host_party: str | None,
153
+ host_key_held: bool,
154
+ subscription: SubscriptionState,
155
+ subscription_right: SubscriptionRight | None,
156
+ runtime_modes: Collection[AuthMode],
157
+ ) -> AuthMode:
158
+ """The mode the named access runs in, or a refusal naming what is missing.
159
+
160
+ ``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.
164
+
165
+ ``subscription_right`` is what the last heartbeat ack said of the Contract's own
166
+ subscription (``llm_contracts``), so a misrouted Directive is refused here as well as
167
+ at the platform's routing (QTS-1349 controls 4 and 7, QTS-1751). ``None`` while no ack
168
+ has named the Contracts -- a control plane below 3.25 -- which leaves routing the only
169
+ check, as before.
170
+ """
171
+
172
+ if access.kind == LlmAccessKind.HOST_KEY:
173
+ if not host_key_held:
174
+ raise AuthModeRefusedError(
175
+ HOST_KEY_NOT_HELD_RULE,
176
+ "this Directive was routed to the provider's key in this Runner's own store, "
177
+ "and the store no longer holds a key the provider accepts",
178
+ )
179
+ return AuthMode.API_KEY
180
+ if access.kind == LlmAccessKind.SUBSCRIPTION:
181
+ if host_party != "user":
182
+ raise AuthModeRefusedError(
183
+ SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE,
184
+ f"this Runner is hosted by {host_party!r}; a subscription runs only on its "
185
+ "person's own Runner",
186
+ )
187
+ if subscription_right == SubscriptionRight.NO_RIGHT:
188
+ raise AuthModeRefusedError(
189
+ SUBSCRIPTION_NO_RIGHT_RULE,
190
+ "this Directive was routed to the person's subscription, and the control plane "
191
+ "last told this Runner that the Contract does not allow one",
192
+ )
193
+ if subscription_right == SubscriptionRight.NOT_ACKNOWLEDGED:
194
+ raise AuthModeRefusedError(
195
+ SUBSCRIPTION_NOT_ACKNOWLEDGED_RULE,
196
+ "this Directive was routed to the person's subscription, and the control plane "
197
+ "last told this Runner that the person has not acknowledged the subscription "
198
+ "terms",
199
+ )
200
+ if AuthMode.SUBSCRIPTION not in runtime_modes:
201
+ raise AuthModeRefusedError(
202
+ NO_RUNTIME_SUBSCRIPTION_RULE,
203
+ "this Runner's runtime for the Directive's CLI kind runs on API keys only",
204
+ )
205
+ if subscription != SubscriptionState.SIGNED_IN:
206
+ raise AuthModeRefusedError(
207
+ SUBSCRIPTION_NOT_SIGNED_IN_RULE,
208
+ f"this Directive was routed to the person's subscription, and its sign-in on "
209
+ f"this Runner is {subscription.value}; run `agentic-runner sign-in` here",
210
+ )
211
+ return AuthMode.SUBSCRIPTION
212
+ 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
+ )
218
+ raise AuthModeRefusedError(
219
+ LLM_ACCESS_UNKNOWN_RULE, f"this Runner does not know the LLM access {access.kind!r}"
220
+ )
221
+
222
+
125
223
  def is_setup_token(value: str) -> bool:
126
224
  return value.strip().startswith(_SETUP_TOKEN_PREFIX)
@@ -15,9 +15,10 @@ the Organisation's cap.
15
15
  Runner (PRD issue 47, ``workstation.py``): one login agent per Organisation, managed by
16
16
  the user who hosts it, with no ``sudo`` anywhere.
17
17
 
18
- ``sign-in --contract <id>`` signs one Contract's Claude Code in with Anthropic's own flow,
19
- in this terminal, as that Contract's uid in its harness root (QTS-1448). No code or token
20
- passes through the control plane.
18
+ ``sign-in [claude|codex]`` gives a person's own Runner their LLM access: their subscription,
19
+ through the vendor's own sign-in in this terminal, or an API key stored in the Runner's
20
+ own store (``sign_in.py``, Spec: Runners rev 3). No ids, and no code or token passes
21
+ through the control plane.
21
22
 
22
23
  ``skill install | print`` hands the setup skill to the person's own Claude Code or Codex
23
24
  (``skill.py``, console-v2 issue 21).
@@ -37,10 +38,11 @@ import asyncio
37
38
  import getpass
38
39
  import json
39
40
  import os
41
+ import re
42
+ import socket
40
43
  import sys
41
44
  from collections.abc import Sequence
42
45
  from pathlib import Path
43
- from uuid import UUID
44
46
 
45
47
  from agentic_runner import __version__ as runner_version
46
48
  from agentic_runner import service, skill, workstation
@@ -66,11 +68,14 @@ from agentic_runner.service import (
66
68
  parse_tags,
67
69
  registration_token_from_env,
68
70
  )
71
+ from agentic_runner.sign_in import AGENT_RUNTIMES, KeyStore
69
72
  from agentic_runner.workers.claude_sign_in import OAUTH_TOKEN, SIGN_IN_METHODS
70
73
  from agentic_runner_contracts import __version__ as contracts_version
71
74
  from agentic_runner_contracts.runner_registration import (
72
75
  HostAttestation,
73
76
  InstallChannel,
77
+ InstallTarget,
78
+ RunnerInstall,
74
79
  SessionKind,
75
80
  StoreKind,
76
81
  )
@@ -212,17 +217,26 @@ def main(argv: Sequence[str] | None = None) -> int:
212
217
 
213
218
  sign_in = subcommands.add_parser(
214
219
  "sign-in",
215
- help="sign a Contract's Claude Code in with Anthropic's own flow, in this terminal",
220
+ help="give your own Runner your subscription or an API key, for Claude Code or Codex",
221
+ )
222
+ sign_in.add_argument(
223
+ "runtime",
224
+ nargs="?",
225
+ choices=tuple(AGENT_RUNTIMES),
226
+ help="one Agent Runtime; omitted asks about each one this Runner serves",
216
227
  )
217
- sign_in.add_argument("--contract", required=True, help="the Contract's id")
218
228
  sign_in.add_argument(
219
229
  "--method",
220
230
  default=OAUTH_TOKEN,
221
231
  choices=SIGN_IN_METHODS,
222
- help="oauth_token: a long-lived token (claude setup-token); claude_ai: a Claude.ai "
223
- "login (claude auth login --claudeai)",
232
+ help="Claude subscriptions only. oauth_token: a long-lived token (claude setup-token); "
233
+ "claude_ai: a Claude.ai login (claude auth login --claudeai)",
234
+ )
235
+ sign_in.add_argument(
236
+ "--org",
237
+ default=None,
238
+ help="the workstation Organisation, when this workstation runs more than one",
224
239
  )
225
- sign_in.add_argument("--org", default=None, help="a workstation Organisation (issue 47)")
226
240
  sign_in.add_argument("--root", default=None, type=Path, help=argparse.SUPPRESS)
227
241
 
228
242
  annotate = subcommands.add_parser("annotate", help="attach an annotation to this Work Record")
@@ -313,7 +327,7 @@ def main(argv: Sequence[str] | None = None) -> int:
313
327
  if arguments.command == "run":
314
328
  if arguments.org:
315
329
  return workstation.run_process(_org_paths(arguments), login_agent=arguments.login_agent)
316
- return asyncio.run(service.run(attestation=_image_attestation()))
330
+ return asyncio.run(service.run(attestation=_image_attestation(), install=_run_install()))
317
331
  if arguments.command in {"install", "start", "stop", "credential"}:
318
332
  return _workstation(arguments)
319
333
  if arguments.command == "recipient-key":
@@ -390,29 +404,89 @@ def _image_attestation() -> HostAttestation:
390
404
  else InstallChannel.MANUAL
391
405
  ),
392
406
  session=SessionKind.CONTAINER,
393
- store=StoreKind.FILE if load_config().credential_store is not None else StoreKind.NONE,
407
+ # Always a store: the state directory's own, where `sign-in` keeps a key, read
408
+ # before any mounted Secret (`service.build_credential_resolver`).
409
+ store=StoreKind.FILE,
394
410
  path=os.environ.get("PATH", ""),
395
411
  )
396
412
 
397
413
 
398
- def _sign_in(arguments: argparse.Namespace) -> int:
414
+ def _run_install() -> RunnerInstall:
415
+ """Where a command reaches this ``run``: Helm with the pod name, Docker with the
416
+ container id, and anything else as a workstation, whose hostname can name its person
417
+ and is never reported (rev 3).
418
+
419
+ Docker's hostname is sent only in the container-id shape Docker gives it: with
420
+ ``--network host`` it is the machine's own name, and with ``--hostname`` whatever the
421
+ operator chose. A name the schema refuses is sent as none rather than stop the start.
422
+ """
423
+
424
+ if os.environ.get("KUBERNETES_SERVICE_HOST"):
425
+ target, hostname = InstallTarget.HELM, socket.gethostname()
426
+ elif _in_docker():
427
+ target, hostname = InstallTarget.DOCKER, socket.gethostname()
428
+ if not _CONTAINER_ID.fullmatch(hostname):
429
+ hostname = ""
430
+ else:
431
+ return RunnerInstall(target=InstallTarget.WORKSTATION)
399
432
  try:
400
- contract_id = str(UUID(arguments.contract))
433
+ return RunnerInstall(target=target, hostname=hostname or None)
401
434
  except ValueError:
402
- print(f"--contract expects a Contract id, got {arguments.contract!r}", file=sys.stderr)
403
- return 2
435
+ return RunnerInstall(target=target)
436
+
437
+
438
+ _CONTAINER_ID = re.compile(r"[0-9a-f]{12}")
439
+
440
+
441
+ def _in_docker() -> bool:
442
+ return Path("/.dockerenv").exists()
443
+
444
+
445
+ def _in_container() -> bool:
446
+ return bool(os.environ.get("KUBERNETES_SERVICE_HOST")) or _in_docker()
447
+
448
+
449
+ def _sign_in(arguments: argparse.Namespace) -> int:
404
450
  if not (os.isatty(0) and os.isatty(1)):
405
451
  print(
406
452
  "sign-in is interactive: run it in a terminal (`kubectl exec -it`, `docker exec -it`)",
407
453
  file=sys.stderr,
408
454
  )
409
455
  return 2
410
- if arguments.org:
411
- paths = _org_paths(arguments)
456
+ store: KeyStore
457
+ if arguments.org or not _in_container():
458
+ try:
459
+ paths = _sign_in_org(arguments)
460
+ except ValueError as error:
461
+ print(error, file=sys.stderr)
462
+ return 2
412
463
  os.environ.update(
413
464
  workstation.run_environment(paths, workstation.WorkstationSettings.load(paths))
414
465
  )
415
- return asyncio.run(service.sign_in_claude(contract_id, method=arguments.method))
466
+ store = open_workstation_store(paths.org, fallback=paths.credentials)
467
+ else:
468
+ store = service.own_store(load_config())
469
+ return asyncio.run(service.sign_in(arguments.runtime, store=store, method=arguments.method))
470
+
471
+
472
+ def _sign_in_org(arguments: argparse.Namespace) -> workstation.OrgPaths:
473
+ """``--org``, or the one Organisation this workstation runs -- no id to type."""
474
+
475
+ if arguments.org:
476
+ return _org_paths(arguments)
477
+ root = arguments.root or workstation.default_root()
478
+ installed = (
479
+ sorted(path.name for path in root.iterdir() if (path / "workstation.json").is_file())
480
+ if root.is_dir()
481
+ else []
482
+ )
483
+ if len(installed) != 1:
484
+ raise ValueError(
485
+ "no workstation Runner is installed here"
486
+ if not installed
487
+ else f"this workstation runs {', '.join(installed)}: name one with --org"
488
+ )
489
+ return workstation.OrgPaths(root=root, org=installed[0])
416
490
 
417
491
 
418
492
  def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int: