agentic-runner 3.24.0__tar.gz → 3.25.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/PKG-INFO +2 -2
  2. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/pyproject.toml +1 -1
  3. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/activities.py +72 -15
  5. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/auth_mode.py +74 -0
  6. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/cli.py +92 -18
  7. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/credentials.py +27 -36
  8. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/heartbeat_link.py +0 -1
  9. agentic_runner-3.25.0/src/agentic_runner/llm_contracts.py +41 -0
  10. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/llm_proxy.py +168 -81
  11. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/service.py +273 -139
  12. agentic_runner-3.25.0/src/agentic_runner/sign_in.py +156 -0
  13. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +23 -26
  14. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/testing/control_plane.py +23 -0
  15. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/testing/test_conformance.py +8 -0
  16. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/claude_sign_in.py +12 -6
  17. agentic_runner-3.25.0/src/agentic_runner/workers/harness_logins.py +172 -0
  18. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workstation.py +5 -1
  19. agentic_runner-3.24.0/src/agentic_runner/device_login_activities.py +0 -119
  20. agentic_runner-3.24.0/src/agentic_runner/workers/contract_device_login.py +0 -241
  21. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/.gitignore +0 -0
  22. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/LICENSE +0 -0
  23. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/README.md +0 -0
  24. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/attempts.py +0 -0
  25. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/build.py +0 -0
  26. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/callback.py +0 -0
  27. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/child_watcher.py +0 -0
  28. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/cli_floor.py +0 -0
  29. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/config.py +0 -0
  30. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/egress.py +0 -0
  31. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/harness_self_test.py +0 -0
  32. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/hooks.py +0 -0
  33. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/host_store.py +0 -0
  34. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/__init__.py +0 -0
  35. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  36. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  37. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  38. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  39. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  40. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  41. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  42. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  43. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  44. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  45. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  46. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/launch.py +0 -0
  47. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/lifecycle.py +0 -0
  48. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/mcp.py +0 -0
  49. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/message_store.py +0 -0
  50. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/oauth_connectors.py +0 -0
  51. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/private_state.py +0 -0
  52. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/py.typed +0 -0
  53. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  54. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/registration.py +0 -0
  55. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/runner_limit.py +0 -0
  56. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/runtime/__init__.py +0 -0
  57. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  58. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/sealed_box.py +0 -0
  59. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/skill.py +0 -0
  60. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/testing/__init__.py +0 -0
  61. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/testing/__main__.py +0 -0
  62. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/testing/plugin.py +0 -0
  63. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/testing/workflow.py +0 -0
  64. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/tiny_http.py +0 -0
  65. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/triage_activities.py +0 -0
  66. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/usage_windows.py +0 -0
  67. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/user_sources.py +0 -0
  68. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/__init__.py +0 -0
  69. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  70. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  71. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  72. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  73. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  74. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/command_policy.py +0 -0
  75. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  76. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  77. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  78. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  79. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  80. {agentic_runner-3.24.0 → agentic_runner-3.25.0}/src/agentic_runner/workers/settings.py +0 -0
  81. {agentic_runner-3.24.0 → agentic_runner-3.25.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.24.0
3
+ Version: 3.25.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.24.0
8
+ Requires-Dist: agentic-runner-contracts==3.25.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.24.0",
13
+ "agentic-runner-contracts==3.25.0",
14
14
  # Imported directly by `registration.py` (the Ed25519 identity that signs every
15
15
  # heartbeat); already present transitively through pyjwt[crypto], declared because
16
16
  # we import it.
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.24.0"
10
+ __version__ = "3.25.0"
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,7 @@ from agentic_runner_contracts.routing import (
286
292
  assert_routed,
287
293
  selector_satisfied,
288
294
  )
295
+ from agentic_runner_contracts.runner_registration import SubscriptionState
289
296
  from agentic_runner_contracts.runtime_context import (
290
297
  EGRESS_ALLOW_LIST_KEY,
291
298
  McpServerSpec,
@@ -2650,7 +2657,8 @@ class RunnerRalphActivities:
2650
2657
  the bearer defensible under ADR-0011 §9.
2651
2658
  """
2652
2659
 
2653
- auth_mode = await self._directive_auth_mode(work_record_id, state)
2660
+ access = routing.llm_access if routing is not None else None
2661
+ auth_mode = await self._directive_auth_mode(work_record_id, state, access)
2654
2662
  await self._report_login_residue(work_record_id, state)
2655
2663
  facts = AttemptFacts(
2656
2664
  work_record_id=work_record_id,
@@ -2669,9 +2677,7 @@ class RunnerRalphActivities:
2669
2677
  )
2670
2678
 
2671
2679
  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
- )
2680
+ credentials = await self._resolve_credentials(work_record_id, state)
2675
2681
  if credentials is not None:
2676
2682
  # Names only, at the moment they resolved (22 A1's Evidence): a support read
2677
2683
  # of "which credentials did this Directive have" must not be answerable by
@@ -2716,7 +2722,8 @@ class RunnerRalphActivities:
2716
2722
  if self._llm_proxy is not None and auth_mode == AuthMode.API_KEY:
2717
2723
  # API-key mode only (issue 43): a subscription Directive never has a slot
2718
2724
  # to resolve, and issue 31 meters it from the harness's own output. One
2719
- # bearer serves the callback socket and the proxy alike.
2725
+ # bearer serves the callback socket and the proxy alike. The attempt spends
2726
+ # its own provider's slot, and a `host_key` Directive the store's alone.
2720
2727
  proxy_attempt = await stack.enter_async_context(
2721
2728
  self._llm_proxy.attempt(
2722
2729
  directive_id=facts.directive_id,
@@ -2725,6 +2732,11 @@ class RunnerRalphActivities:
2725
2732
  work_record_id=_optional_uuid(work_record_id),
2726
2733
  token=callback_env.get(CALLBACK_TOKEN_ENV),
2727
2734
  reserve_max_tokens=reserve_max_tokens,
2735
+ provider=provider_for(state.cli_kind),
2736
+ host_key_only=(
2737
+ access is not None and access.kind == LlmAccessKind.HOST_KEY
2738
+ ),
2739
+ delivered_only=access is None and not self._personal_runner(),
2728
2740
  )
2729
2741
  )
2730
2742
  callback_env.update(proxy_attempt.env(state.cli_kind))
@@ -3081,8 +3093,6 @@ class RunnerRalphActivities:
3081
3093
  self,
3082
3094
  work_record_id: str,
3083
3095
  state: _RuntimeContextState,
3084
- *,
3085
- funding_party: str | None = None,
3086
3096
  ) -> DirectiveCredentials | None:
3087
3097
  """Resolve this Contract's Credential References, or fail the Directive closed.
3088
3098
 
@@ -3100,7 +3110,6 @@ class RunnerRalphActivities:
3100
3110
  return resolver.resolve(
3101
3111
  contract_id=state.contract_id,
3102
3112
  manifest=state.credential_references,
3103
- funding_party=funding_party,
3104
3113
  )
3105
3114
  except UnresolvableCredentialReferenceError as error:
3106
3115
  await self._fastapi_client.append_evidence(
@@ -3111,11 +3120,15 @@ class RunnerRalphActivities:
3111
3120
  raise
3112
3121
 
3113
3122
  async def _directive_auth_mode(
3114
- self, work_record_id: str, state: _RuntimeContextState
3123
+ self, work_record_id: str, state: _RuntimeContextState, access: LlmAccess | None = None
3115
3124
  ) -> AuthMode:
3116
3125
  """This Directive's auth mode, or a non-retryable refusal with Evidence naming the
3117
3126
  rule (local-agents 04).
3118
3127
 
3128
+ ``access`` is the LLM access the control plane routed the Directive with (rev 3):
3129
+ the Runner runs on exactly that, or refuses and never falls back to other access.
3130
+ None, from a control plane below contracts 3.25, keeps the Runner's own choice.
3131
+
3119
3132
  Decided from what this process can check -- the host party it was registered with,
3120
3133
  whether a key is present for the Contract, whether the Contract runs under its own
3121
3134
  uid here and whether its harness root holds a login (QTS-1450) -- never from the
@@ -3129,6 +3142,14 @@ class RunnerRalphActivities:
3129
3142
  host_party = self._routing_identity.host_party if self._routing_identity else None
3130
3143
  contract_uid = self._runs_under_contract_uid(state)
3131
3144
  try:
3145
+ if access is not None:
3146
+ return require_named_access(
3147
+ access,
3148
+ host_party=host_party,
3149
+ host_key_held=self._host_key_held(state),
3150
+ subscription=self._subscription_state(state),
3151
+ runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
3152
+ )
3132
3153
  return choose_auth_mode(
3133
3154
  host_party=host_party,
3134
3155
  key_present=self._api_key_present(state, runtime),
@@ -3148,11 +3169,16 @@ class RunnerRalphActivities:
3148
3169
  source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3149
3170
  actor=self._actor(),
3150
3171
  payload={
3151
- "event": "directive.auth_mode_refused",
3172
+ "event": (
3173
+ "directive.auth_mode_refused"
3174
+ if access is None
3175
+ else "directive.llm_access_refused"
3176
+ ),
3152
3177
  "rule": refusal.rule,
3153
3178
  "cli_kind": state.cli_kind,
3154
3179
  "host_party": host_party,
3155
3180
  "contract_id": state.contract_id,
3181
+ **({} if access is None else {"llm_access": access.kind}),
3156
3182
  },
3157
3183
  )
3158
3184
 
@@ -3167,16 +3193,47 @@ class RunnerRalphActivities:
3167
3193
  )
3168
3194
 
3169
3195
  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."""
3196
+ """A slot the attempt's provider would resolve: delivered for the Contract, or the
3197
+ host store's key (rev 3); or a key the host configured on the runtime itself."""
3171
3198
 
3172
3199
  if bool(getattr(runtime, "host_api_key", False)):
3173
3200
  return True
3174
- contract_id = _optional_uuid(state.contract_id)
3201
+ return self._llm_proxy is not None and self._llm_proxy.slots.holds(
3202
+ _optional_uuid(state.contract_id),
3203
+ provider_for(state.cli_kind),
3204
+ delivered_only=not self._personal_runner(),
3205
+ )
3206
+
3207
+ def _personal_runner(self) -> bool:
3208
+ """Hosted by its person. Only such a Runner spends its store's key on a Directive
3209
+ that names no access (a control plane below 3.25): a pool Runner's key never
3210
+ reached the proxy before, and that control plane caps none of its spend."""
3211
+
3212
+ return self._routing_identity is not None and self._routing_identity.host_party == "user"
3213
+
3214
+ def _host_key_held(self, state: _RuntimeContextState) -> bool:
3215
+ provider = provider_for(state.cli_kind)
3175
3216
  return (
3176
3217
  self._llm_proxy is not None
3177
- and contract_id is not None
3178
- and self._llm_proxy.slots.holds(contract_id)
3218
+ and provider is not None
3219
+ and self._llm_proxy.slots.holds_host(provider.value)
3220
+ )
3221
+
3222
+ def _subscription_state(self, state: _RuntimeContextState) -> SubscriptionState:
3223
+ if self._contract_isolation is None or not state.contract_id:
3224
+ return SubscriptionState.NONE
3225
+ windows = (
3226
+ self._usage_windows.windows(state.contract_id, state.cli_kind)
3227
+ if self._usage_windows is not None
3228
+ else None
3179
3229
  )
3230
+ return subscription_report(
3231
+ self._contract_isolation,
3232
+ state.contract_id,
3233
+ state.cli_kind,
3234
+ windows=windows,
3235
+ now=datetime.now(UTC),
3236
+ ).state
3180
3237
 
3181
3238
  async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
3182
3239
  """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,24 @@ 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 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_SIGNED_IN_RULE",
42
+ "SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE",
30
43
  "SHARED_HOST_PARTIES",
31
44
  "SIGN_IN_NEEDS_CONTRACT_UID_RULE",
32
45
  "SUBSCRIPTION_NEEDS_CONTRACT_UID_RULE",
33
46
  "SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE",
34
47
  "AuthModeRefusedError",
35
48
  "choose_auth_mode",
49
+ "require_named_access",
36
50
  "is_setup_token",
37
51
  "is_shared_runner",
38
52
  "runs_subscriptions",
@@ -46,6 +60,12 @@ SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE: Final[str] = "subscription_needs_harness_
46
60
  NO_RUNTIME_SUBSCRIPTION_RULE: Final[str] = "runtime_has_no_subscription_mode"
47
61
  SIGN_IN_NEEDS_CONTRACT_UID_RULE: Final[str] = "sign_in_needs_contract_uid"
48
62
  SETUP_TOKEN_REFUSED_RULE: Final[str] = "setup_token_refused"
63
+ # A Directive whose named `llm_access` this Runner no longer holds (rev 3).
64
+ HOST_KEY_NOT_HELD_RULE: Final[str] = "host_key_not_held"
65
+ SUBSCRIPTION_NOT_SIGNED_IN_RULE: Final[str] = "subscription_not_signed_in"
66
+ SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE: Final[str] = "subscription_off_personal_runner"
67
+ LLM_KEY_UNSUPPORTED_RULE: Final[str] = "llm_key_unsupported"
68
+ LLM_ACCESS_UNKNOWN_RULE: Final[str] = "llm_access_unknown"
49
69
  # `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
50
70
  # `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
51
71
  # Runner can refuse on.
@@ -122,5 +142,59 @@ def choose_auth_mode(
122
142
  return AuthMode.SUBSCRIPTION
123
143
 
124
144
 
145
+ def require_named_access(
146
+ access: LlmAccess,
147
+ *,
148
+ host_party: str | None,
149
+ host_key_held: bool,
150
+ subscription: SubscriptionState,
151
+ runtime_modes: Collection[AuthMode],
152
+ ) -> AuthMode:
153
+ """The mode the named access runs in, or a refusal naming what is missing.
154
+
155
+ ``host_key_held`` is the provider's key in this Runner's own store, serving in the
156
+ proxy; ``subscription`` the Contract's harness root for the Directive's kind. A
157
+ subscription runs only on its person's own Runner (rev 3: never on a pool Runner),
158
+ and only while it is signed in, unexpired and not at its usage limit.
159
+ """
160
+
161
+ if access.kind == LlmAccessKind.HOST_KEY:
162
+ if not host_key_held:
163
+ raise AuthModeRefusedError(
164
+ HOST_KEY_NOT_HELD_RULE,
165
+ "this Directive was routed to the provider's key in this Runner's own store, "
166
+ "and the store no longer holds a key the provider accepts",
167
+ )
168
+ return AuthMode.API_KEY
169
+ if access.kind == LlmAccessKind.SUBSCRIPTION:
170
+ if host_party != "user":
171
+ raise AuthModeRefusedError(
172
+ SUBSCRIPTION_OFF_PERSONAL_RUNNER_RULE,
173
+ f"this Runner is hosted by {host_party!r}; a subscription runs only on its "
174
+ "person's own Runner",
175
+ )
176
+ if AuthMode.SUBSCRIPTION not in runtime_modes:
177
+ raise AuthModeRefusedError(
178
+ NO_RUNTIME_SUBSCRIPTION_RULE,
179
+ "this Runner's runtime for the Directive's CLI kind runs on API keys only",
180
+ )
181
+ if subscription != SubscriptionState.SIGNED_IN:
182
+ raise AuthModeRefusedError(
183
+ SUBSCRIPTION_NOT_SIGNED_IN_RULE,
184
+ f"this Directive was routed to the person's subscription, and its sign-in on "
185
+ f"this Runner is {subscription.value}; run `agentic-runner sign-in` here",
186
+ )
187
+ return AuthMode.SUBSCRIPTION
188
+ if access.kind == LlmAccessKind.LLM_KEY:
189
+ raise AuthModeRefusedError(
190
+ LLM_KEY_UNSUPPORTED_RULE,
191
+ "this Directive was routed to one of the pool's LLM Keys, which this Runner "
192
+ "release cannot open; update the Runner",
193
+ )
194
+ raise AuthModeRefusedError(
195
+ LLM_ACCESS_UNKNOWN_RULE, f"this Runner does not know the LLM access {access.kind!r}"
196
+ )
197
+
198
+
125
199
  def is_setup_token(value: str) -> bool:
126
200
  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:
@@ -44,6 +44,7 @@ __all__ = [
44
44
  "EmptyCredentialStore",
45
45
  "FakeCredentialStore",
46
46
  "HostCredentialStore",
47
+ "LayeredCredentialStore",
47
48
  "UnresolvableCredentialReferenceError",
48
49
  ]
49
50
 
@@ -115,6 +116,25 @@ class EmptyCredentialStore:
115
116
  return None
116
117
 
117
118
 
119
+ class LayeredCredentialStore:
120
+ """Several host stores read as one, the first that holds a value winning.
121
+
122
+ A container Runner's store is a mounted Secret, read-only, so a key the person types
123
+ into ``agentic-runner sign-in`` goes to a store in the state directory, read first:
124
+ it is the person's later act on their own Runner.
125
+ """
126
+
127
+ def __init__(self, *stores: HostCredentialStore) -> None:
128
+ self._stores = stores
129
+
130
+ def get(self, reference: str) -> str | None:
131
+ for store in self._stores:
132
+ value = store.get(reference)
133
+ if value is not None:
134
+ return value
135
+ return None
136
+
137
+
118
138
  class UnresolvableCredentialReferenceError(RuntimeError):
119
139
  """A Directive named a Credential Reference it cannot have (22 A1's fail-closed half).
120
140
 
@@ -205,12 +225,9 @@ class CredentialResolver:
205
225
  value is stating it is theirs to state; the host's own install is the fallback, which
206
226
  is exactly the hand-over default.
207
227
 
208
- The fallback is a loan of the host's own values, so it is made only to work the host
209
- pays for (ADR-0022 §36): an Organisation or Account Runner lends its store to a
210
- Contract whose ``funding_party`` is its own ``host_party``, and a contractor-funded
211
- Contract in that pool brings sealed values or finds the reference unusable. A
212
- personal Runner lends to every Contract it runs, because it runs only its person's
213
- own (§2, §42). A blank ``host_party`` -- a Runner not yet registered -- lends nothing.
228
+ The host's store serves every Contract the Runner runs (Spec: Runners rev 3): the
229
+ Contract's hosts already say whose Runners, and so whose secrets, its work may use.
230
+ ADR-0022 §36's limit to the Contracts its host funds went with `funding`.
214
231
  """
215
232
 
216
233
  def __init__(
@@ -218,14 +235,9 @@ class CredentialResolver:
218
235
  *,
219
236
  store: HostCredentialStore,
220
237
  delivered: Mapping[tuple[str, str], str] | None = None,
221
- host_party: str | None = None,
222
238
  ) -> None:
223
239
  self._store = store
224
240
  self._delivered = dict(delivered or {})
225
- self._host_party = host_party
226
- # The funding party the last ack named per Contract, for the readers that have no
227
- # Directive to carry one: the heartbeat's report and an OAuth Connector's secret.
228
- self._funding: dict[str, str] = {}
229
241
 
230
242
  @property
231
243
  def host_store(self) -> HostCredentialStore:
@@ -243,33 +255,22 @@ class CredentialResolver:
243
255
 
244
256
  self._delivered.pop((str(contract_id), slot), None)
245
257
 
246
- def fund(self, funding: Mapping[str, str]) -> None:
247
- """Replace the per-Contract funding parties with the last ack's (ADR-0022 §7)."""
248
-
249
- self._funding = dict(funding)
250
-
251
258
  def resolve(
252
259
  self,
253
260
  *,
254
261
  contract_id: str | None,
255
262
  manifest: Iterable[str],
256
- funding_party: str | None = None,
257
263
  ) -> DirectiveCredentials:
258
264
  """Every reference the Contract declares, or the first failure, named.
259
265
 
260
- ``funding_party`` is the Directive's own (``DirectiveRouting.funding_party``); a
261
- reader without a Directive leaves it out and the last ack's word for the Contract
262
- stands in, and with neither the host store is not lent.
263
-
264
266
  Fails on the *first* unresolvable reference rather than collecting them: a
265
267
  Directive that cannot have one of its credentials is not going to run, and a
266
268
  partial resolution left lying around is a value held for no reason.
267
269
  """
268
270
 
269
- lends = self._lends_host_store(contract_id, funding_party)
270
271
  values: dict[str, str] = {}
271
272
  for reference in manifest:
272
- value = self._value(contract_id, reference, lends=lends)
273
+ value = self._value(contract_id, reference)
273
274
  if value is None:
274
275
  raise UnresolvableCredentialReferenceError(
275
276
  reference, contract_id=contract_id, reason="not_installed"
@@ -280,18 +281,8 @@ class CredentialResolver:
280
281
  def usable(self, contract_id: str, reference: str) -> bool:
281
282
  """Whether a Directive of this Contract would resolve ``reference`` now (§7)."""
282
283
 
283
- lends = self._lends_host_store(contract_id, None)
284
- return self._value(contract_id, reference, lends=lends) is not None
284
+ return self._value(contract_id, reference) is not None
285
285
 
286
- def _value(self, contract_id: str | None, reference: str, *, lends: bool) -> str | None:
286
+ def _value(self, contract_id: str | None, reference: str) -> str | None:
287
287
  value = self._delivered.get((str(contract_id), reference))
288
- if value is None and lends:
289
- value = self._store.get(reference)
290
- return value
291
-
292
- def _lends_host_store(self, contract_id: str | None, funding_party: str | None) -> bool:
293
- if self._host_party == "user":
294
- return True
295
- if funding_party is None:
296
- funding_party = self._funding.get(str(contract_id), "")
297
- return bool(self._host_party) and funding_party == self._host_party
288
+ return value if value is not None else self._store.get(reference)
@@ -235,7 +235,6 @@ class HeartbeatLink:
235
235
  contract_used=ceiling.contract_used,
236
236
  organisation_limit=ceiling.organisation_limit,
237
237
  organisation_used=ceiling.organisation_used,
238
- org_funded=ceiling.org_funded,
239
238
  ),
240
239
  )
241
240