agentic-runner 3.13.0__tar.gz → 3.15.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 (77) hide show
  1. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/activities.py +32 -10
  4. agentic_runner-3.15.0/src/agentic_runner/auth_mode.py +126 -0
  5. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/cli.py +43 -0
  6. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/device_login_activities.py +19 -21
  7. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/sealed_box.py +1 -12
  8. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/service.py +73 -43
  9. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +7 -4
  10. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/claude_sign_in.py +188 -250
  11. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/contract_device_login.py +21 -24
  12. agentic_runner-3.13.0/src/agentic_runner/auth_mode.py +0 -86
  13. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/.gitignore +0 -0
  14. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/LICENSE +0 -0
  15. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/README.md +0 -0
  16. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/pyproject.toml +0 -0
  17. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/build.py +0 -0
  19. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/callback.py +0 -0
  20. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/child_watcher.py +0 -0
  21. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/cli_floor.py +0 -0
  22. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/config.py +0 -0
  23. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/credentials.py +0 -0
  24. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/egress.py +0 -0
  25. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/harness_self_test.py +0 -0
  26. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/heartbeat_link.py +0 -0
  27. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/hooks.py +0 -0
  28. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/host_store.py +0 -0
  29. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/__init__.py +0 -0
  30. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  31. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  32. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  33. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  34. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  35. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  36. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  37. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  38. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  39. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  40. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  41. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/lifecycle.py +0 -0
  42. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/llm_proxy.py +0 -0
  43. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/mcp.py +0 -0
  44. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/message_store.py +0 -0
  45. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/oauth_connectors.py +0 -0
  46. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/private_state.py +0 -0
  47. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/py.typed +0 -0
  48. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  49. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/registration.py +0 -0
  50. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/runtime/__init__.py +0 -0
  51. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  52. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/skill.py +0 -0
  53. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/testing/__init__.py +0 -0
  54. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/testing/__main__.py +0 -0
  55. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/testing/control_plane.py +0 -0
  56. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/testing/plugin.py +0 -0
  57. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  58. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/testing/workflow.py +0 -0
  59. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/tiny_http.py +0 -0
  60. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/triage_activities.py +0 -0
  61. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/usage_windows.py +0 -0
  62. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/user_sources.py +0 -0
  63. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/__init__.py +0 -0
  64. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  65. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  66. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  67. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  68. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  69. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/command_policy.py +0 -0
  70. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  71. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  72. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  73. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  74. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  75. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/settings.py +0 -0
  76. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workers/skills.py +0 -0
  77. {agentic_runner-3.13.0 → agentic_runner-3.15.0}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.13.0
3
+ Version: 3.15.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,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.13.0"
10
+ __version__ = "3.15.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -137,7 +137,7 @@ from agentic_runner.workers.agent_runtime import (
137
137
  PermissionFallback,
138
138
  ResumableAgentRuntime,
139
139
  )
140
- from agentic_runner.workers.contract_device_login import login_file_counts
140
+ from agentic_runner.workers.contract_device_login import holds_login, login_file_counts
141
141
  from agentic_runner.workers.contract_isolation import (
142
142
  NO_CONTRACT,
143
143
  ContractIsolation,
@@ -2995,20 +2995,29 @@ class RunnerRalphActivities:
2995
2995
  """This Directive's auth mode, or a non-retryable refusal with Evidence naming the
2996
2996
  rule (local-agents 04).
2997
2997
 
2998
- Decided from the host party this process was registered with and from whether a
2999
- key is present for the Contract -- never from the payload. A runtime that declares
3000
- no modes (a test double built before the port grew the field) reads as
3001
- ``api_key``-only, and a kind this Runner does not serve reads the same way; that
3002
- Directive fails closed in :meth:`_agent_runtime_for` regardless.
2998
+ Decided from what this process can check -- the host party it was registered with,
2999
+ whether a key is present for the Contract, whether the Contract runs under its own
3000
+ uid here and whether its harness root holds a login (QTS-1450) -- never from the
3001
+ payload. A runtime that declares no modes (a test double built before the port
3002
+ grew the field) reads as ``api_key``-only, and a kind this Runner does not serve
3003
+ reads the same way; that Directive fails closed in :meth:`_agent_runtime_for`
3004
+ regardless.
3003
3005
  """
3004
3006
 
3005
3007
  runtime = self._agent_runtimes.get(state.cli_kind)
3006
3008
  host_party = self._routing_identity.host_party if self._routing_identity else None
3009
+ contract_uid = self._runs_under_contract_uid(state)
3007
3010
  try:
3008
3011
  return choose_auth_mode(
3009
3012
  host_party=host_party,
3010
3013
  key_present=self._api_key_present(state, runtime),
3011
3014
  runtime_modes=getattr(runtime, "auth_modes", frozenset({AuthMode.API_KEY})),
3015
+ contract_uid=contract_uid,
3016
+ harness_login=(
3017
+ contract_uid
3018
+ and self._contract_isolation is not None
3019
+ and holds_login(self._contract_isolation, state.contract_id, state.cli_kind)
3020
+ ),
3012
3021
  )
3013
3022
  except AuthModeRefusedError as refusal:
3014
3023
  await _raise_recorded(
@@ -3026,6 +3035,16 @@ class RunnerRalphActivities:
3026
3035
  },
3027
3036
  )
3028
3037
 
3038
+ def _runs_under_contract_uid(self, state: _RuntimeContextState) -> bool:
3039
+ """Whether this Directive spawns as its own Contract's uid. A Work Record with no
3040
+ Contract shares the one no-Contract tree, so it never does."""
3041
+
3042
+ return (
3043
+ self._contract_isolation is not None
3044
+ and self._contract_isolation.can_separate_uids
3045
+ and _optional_uuid(state.contract_id) is not None
3046
+ )
3047
+
3029
3048
  def _api_key_present(self, state: _RuntimeContextState, runtime: AgentRuntime | None) -> bool:
3030
3049
  """A delivered slot for the Contract (or its funder), or the host operator's key."""
3031
3050
 
@@ -3039,18 +3058,21 @@ class RunnerRalphActivities:
3039
3058
  )
3040
3059
 
3041
3060
  async def _report_login_residue(self, work_record_id: str, state: _RuntimeContextState) -> None:
3042
- """On a shared Runner, say once that a Contract's harness root holds a login.
3061
+ """On a shared Runner that cannot run a subscription, say once that a Contract's
3062
+ harness root holds a login.
3043
3063
 
3044
3064
  A count per harness, never a path or a byte of the file: the Runner does not open
3045
- it, a shared Runner never runs on it, and ``wipe_contract_residue`` removes it with
3046
- the Contract. Saying so is what turns a sign-in made before local-agents 03 (or by
3047
- hand) into something the Organisation can see and act on.
3065
+ it, a shared Runner without Contract uids never runs on it (QTS-1450), and
3066
+ ``wipe_contract_residue`` removes it with the Contract. Saying so is what turns a
3067
+ sign-in made before local-agents 03 (or by hand) into something the Organisation
3068
+ can see and act on.
3048
3069
  """
3049
3070
 
3050
3071
  host_party = self._routing_identity.host_party if self._routing_identity else None
3051
3072
  if (
3052
3073
  self._contract_isolation is None
3053
3074
  or not is_shared_runner(host_party)
3075
+ or self._runs_under_contract_uid(state)
3054
3076
  or state.contract_id in self._residue_checked
3055
3077
  ):
3056
3078
  return
@@ -0,0 +1,126 @@
1
+ """Which auth mode one Directive runs in, decided on the Runner before spawn (local-agents 04).
2
+
3
+ Subscriptions follow the person and the actual operator, not the host party (QTS-1349,
4
+ QTS-1450). A harness runs on a subscription login where the Runner can verify that the
5
+ login is the Contract's alone:
6
+
7
+ * on the Contract person's own Runner -- host party ``user``, which routing has already
8
+ tied to the Contract's person (``assert_routed`` here, the platform's
9
+ ``_subscription_bound`` there);
10
+ * on a shared Runner (``organisation`` or ``account``, e.g. a customer-operated cluster)
11
+ only when the Directive runs as its Contract's own uid and that Contract's harness root
12
+ holds a login. The uid is what keeps one Contract's login out of every other
13
+ Contract's reach on the same host; the login is what the person made there themselves.
14
+
15
+ The platform decides who may use a subscription at the door (the Subscription Principal
16
+ and the Organisation's opt-in, QTS-1350); this module is the backstop, so it decides from
17
+ what the Runner itself can check and never from the payload.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Collection
23
+ from typing import Final
24
+
25
+ from agentic_runner.workers.agent_runtime import AuthMode
26
+
27
+ __all__ = [
28
+ "NO_RUNTIME_SUBSCRIPTION_RULE",
29
+ "SETUP_TOKEN_REFUSED_RULE",
30
+ "SHARED_HOST_PARTIES",
31
+ "SIGN_IN_NEEDS_CONTRACT_UID_RULE",
32
+ "SUBSCRIPTION_NEEDS_CONTRACT_UID_RULE",
33
+ "SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE",
34
+ "AuthModeRefusedError",
35
+ "choose_auth_mode",
36
+ "is_setup_token",
37
+ "is_shared_runner",
38
+ "runs_subscriptions",
39
+ ]
40
+
41
+ SHARED_HOST_PARTIES: Final[frozenset[str]] = frozenset({"organisation", "account"})
42
+
43
+ # The rule names a refusal's Evidence carries, so a support read can say which line held.
44
+ SUBSCRIPTION_NEEDS_CONTRACT_UID_RULE: Final[str] = "subscription_needs_contract_uid"
45
+ SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE: Final[str] = "subscription_needs_harness_login"
46
+ NO_RUNTIME_SUBSCRIPTION_RULE: Final[str] = "runtime_has_no_subscription_mode"
47
+ SIGN_IN_NEEDS_CONTRACT_UID_RULE: Final[str] = "sign_in_needs_contract_uid"
48
+ SETUP_TOKEN_REFUSED_RULE: Final[str] = "setup_token_refused"
49
+ # `claude setup-token` mints an OAuth bearer under this prefix; an Anthropic API key is
50
+ # `sk-ant-api…`. The sealed wire carries no mode, so the value's own shape is what the
51
+ # Runner can refuse on.
52
+ _SETUP_TOKEN_PREFIX: Final[str] = "sk-ant-oat"
53
+
54
+
55
+ class AuthModeRefusedError(RuntimeError):
56
+ """A Directive or sign-in this Runner will not run; ``rule`` is the stable code."""
57
+
58
+ def __init__(self, rule: str, message: str) -> None:
59
+ super().__init__(message)
60
+ self.rule = rule
61
+
62
+
63
+ def is_shared_runner(host_party: str | None) -> bool:
64
+ return host_party in SHARED_HOST_PARTIES
65
+
66
+
67
+ def runs_subscriptions(host_party: str | None, *, separates_contract_uids: bool) -> bool:
68
+ """Whether a subscription login made on this Runner could ever run a Directive.
69
+
70
+ What a sign-in is checked against: one made where no Directive could use it would
71
+ only leave a login lying in a harness root. An unregistered process (no host party)
72
+ makes no routing assertion, so it is nobody's own Runner and never qualifies.
73
+ """
74
+
75
+ return host_party == "user" or (is_shared_runner(host_party) and separates_contract_uids)
76
+
77
+
78
+ def choose_auth_mode(
79
+ *,
80
+ host_party: str | None,
81
+ key_present: bool,
82
+ runtime_modes: Collection[AuthMode],
83
+ contract_uid: bool,
84
+ harness_login: bool,
85
+ ) -> AuthMode:
86
+ """``api_key`` whenever a key is present; otherwise ``subscription`` where the Runner
87
+ can verify the login is this Contract's, and a refusal on a shared Runner where it
88
+ cannot.
89
+
90
+ ``contract_uid`` says the Directive runs as its own Contract's uid (never the shared
91
+ no-Contract tree's); ``harness_login`` that this Contract's harness root for the
92
+ Directive's kind holds a login. A shared Runner with no key and either one missing has
93
+ nothing left that could authenticate the harness, so it refuses, naming the missing
94
+ one. An unregistered process (``host_party`` ``None``) never runs a subscription; it
95
+ runs on a key or fails at the proxy for want of one.
96
+ """
97
+
98
+ if key_present:
99
+ return AuthMode.API_KEY
100
+ if host_party == "user":
101
+ return AuthMode.SUBSCRIPTION if AuthMode.SUBSCRIPTION in runtime_modes else AuthMode.API_KEY
102
+ if not is_shared_runner(host_party):
103
+ return AuthMode.API_KEY
104
+ if not contract_uid:
105
+ raise AuthModeRefusedError(
106
+ SUBSCRIPTION_NEEDS_CONTRACT_UID_RULE,
107
+ f"this Runner is hosted by {host_party!r} and does not run this Contract under its "
108
+ "own uid, so it runs API keys only, and no API key is present for this Contract",
109
+ )
110
+ if not harness_login:
111
+ raise AuthModeRefusedError(
112
+ SUBSCRIPTION_NEEDS_HARNESS_LOGIN_RULE,
113
+ "no API key is present for this Contract and its harness root on this Runner holds "
114
+ "no login; sign it in on this Runner with `agentic-runner sign-in`",
115
+ )
116
+ if AuthMode.SUBSCRIPTION not in runtime_modes:
117
+ raise AuthModeRefusedError(
118
+ NO_RUNTIME_SUBSCRIPTION_RULE,
119
+ "no API key is present for this Contract and this Runner's runtime for its CLI "
120
+ "kind runs on API keys only",
121
+ )
122
+ return AuthMode.SUBSCRIPTION
123
+
124
+
125
+ def is_setup_token(value: str) -> bool:
126
+ return value.strip().startswith(_SETUP_TOKEN_PREFIX)
@@ -15,6 +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.
21
+
18
22
  ``skill install | print`` hands the setup skill to the person's own Claude Code or Codex
19
23
  (``skill.py``, console-v2 issue 21).
20
24
 
@@ -36,6 +40,7 @@ import os
36
40
  import sys
37
41
  from collections.abc import Sequence
38
42
  from pathlib import Path
43
+ from uuid import UUID
39
44
 
40
45
  from agentic_runner import __version__ as runner_version
41
46
  from agentic_runner import service, skill, workstation
@@ -59,6 +64,7 @@ from agentic_runner.service import (
59
64
  TAGS_ENV,
60
65
  parse_tags,
61
66
  )
67
+ from agentic_runner.workers.claude_sign_in import OAUTH_TOKEN, SIGN_IN_METHODS
62
68
  from agentic_runner_contracts import __version__ as contracts_version
63
69
  from agentic_runner_contracts.runner_registration import (
64
70
  HostAttestation,
@@ -198,6 +204,21 @@ def main(argv: Sequence[str] | None = None) -> int:
198
204
  status.add_argument("org", nargs="?", help="a workstation Organisation (issue 47)")
199
205
  status.add_argument("--root", default=None, type=Path)
200
206
 
207
+ sign_in = subcommands.add_parser(
208
+ "sign-in",
209
+ help="sign a Contract's Claude Code in with Anthropic's own flow, in this terminal",
210
+ )
211
+ sign_in.add_argument("--contract", required=True, help="the Contract's id")
212
+ sign_in.add_argument(
213
+ "--method",
214
+ default=OAUTH_TOKEN,
215
+ choices=SIGN_IN_METHODS,
216
+ help="oauth_token: a long-lived token (claude setup-token); claude_ai: a Claude.ai "
217
+ "login (claude auth login --claudeai)",
218
+ )
219
+ sign_in.add_argument("--org", default=None, help="a workstation Organisation (issue 47)")
220
+ sign_in.add_argument("--root", default=None, type=Path, help=argparse.SUPPRESS)
221
+
201
222
  annotate = subcommands.add_parser("annotate", help="attach an annotation to this Work Record")
202
223
  annotate.add_argument("--context", default="default")
203
224
  annotate.add_argument(
@@ -303,6 +324,8 @@ def main(argv: Sequence[str] | None = None) -> int:
303
324
  for line in lines:
304
325
  print(line)
305
326
  return 0
327
+ if arguments.command == "sign-in":
328
+ return _sign_in(arguments)
306
329
  if arguments.command == "skill":
307
330
  if arguments.action == "print":
308
331
  sys.stdout.write(skill.text())
@@ -366,6 +389,26 @@ def _image_attestation() -> HostAttestation:
366
389
  )
367
390
 
368
391
 
392
+ def _sign_in(arguments: argparse.Namespace) -> int:
393
+ try:
394
+ contract_id = str(UUID(arguments.contract))
395
+ except ValueError:
396
+ print(f"--contract expects a Contract id, got {arguments.contract!r}", file=sys.stderr)
397
+ return 2
398
+ if not (os.isatty(0) and os.isatty(1)):
399
+ print(
400
+ "sign-in is interactive: run it in a terminal (`kubectl exec -it`, `docker exec -it`)",
401
+ file=sys.stderr,
402
+ )
403
+ return 2
404
+ if arguments.org:
405
+ paths = _org_paths(arguments)
406
+ os.environ.update(
407
+ workstation.run_environment(paths, workstation.WorkstationSettings.load(paths))
408
+ )
409
+ return asyncio.run(service.sign_in_claude(contract_id, method=arguments.method))
410
+
411
+
369
412
  def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
370
413
  if not arguments.control_plane:
371
414
  print(f"--control-plane (or {CONTROL_PLANE_ENV}) is required", file=sys.stderr)
@@ -13,8 +13,7 @@ from datetime import UTC, datetime
13
13
  from temporalio import activity
14
14
  from temporalio.exceptions import ApplicationError
15
15
 
16
- from agentic_runner.auth_mode import SHARED_RUNNER_SIGN_IN_RULE, is_shared_runner
17
- from agentic_runner.workers.claude_sign_in import OAUTH_TOKEN
16
+ from agentic_runner.auth_mode import SIGN_IN_NEEDS_CONTRACT_UID_RULE, runs_subscriptions
18
17
  from agentic_runner.workers.contract_device_login import ContractDeviceLogin
19
18
  from agentic_runner.workers.contract_isolation import ContractIsolation
20
19
  from agentic_runner_contracts.activity_io import (
@@ -38,7 +37,8 @@ class ContractDeviceLoginActivities:
38
37
  ) -> None:
39
38
  self._contract_isolation = contract_isolation
40
39
  # The party this process was registered as hosted by. A shared Runner signs in only
41
- # a Claude Code long-lived token (local-agents 04, amended by 21).
40
+ # where its Directives run as each Contract's own uid (QTS-1450); a person signs
41
+ # Claude Code in on the Runner itself (QTS-1448).
42
42
  self._host_party = host_party
43
43
  # Injectable for tests; None falls through to the real vendor CLIs.
44
44
  self._device_login = device_login or ContractDeviceLogin(contract_isolation)
@@ -53,12 +53,9 @@ class ContractDeviceLoginActivities:
53
53
  async def sign_in_contract_device_login(
54
54
  self, request: ContractDeviceLoginInput
55
55
  ) -> ContractDeviceLoginResult:
56
- long_lived = request.runtime_kind == "claude_code" and (request.method or OAUTH_TOKEN) == (
57
- OAUTH_TOKEN
58
- )
59
- self._refuse_on_shared_runner(allow=long_lived)
56
+ self._refuse_where_no_subscription_runs()
60
57
  prompt = await self._device_login.sign_in(
61
- request.contract_id, runtime_kind=request.runtime_kind, method=request.method
58
+ request.contract_id, runtime_kind=request.runtime_kind
62
59
  )
63
60
  return ContractDeviceLoginResult(
64
61
  contract_id=prompt.contract_id,
@@ -66,7 +63,6 @@ class ContractDeviceLoginActivities:
66
63
  verification_uri=prompt.verification_uri,
67
64
  user_code=prompt.user_code,
68
65
  expires_at=prompt.expires_at.isoformat(),
69
- sign_in_id=prompt.sign_in_id,
70
66
  )
71
67
 
72
68
  @activity.defn(name="check_contract_device_login_status")
@@ -74,7 +70,7 @@ class ContractDeviceLoginActivities:
74
70
  self, request: ContractDeviceLoginStatusInput
75
71
  ) -> ContractDeviceLoginStatusResult:
76
72
  if request.runtime_kind == "claude_code":
77
- self._refuse_on_shared_runner(allow=True)
73
+ self._refuse_where_no_subscription_runs()
78
74
  status = await self._device_login.claude.status(request.contract_id)
79
75
  return ContractDeviceLoginStatusResult(
80
76
  contract_id=request.contract_id,
@@ -84,7 +80,7 @@ class ContractDeviceLoginActivities:
84
80
  auth_method=status.auth_method,
85
81
  subscription_type=status.subscription_type,
86
82
  )
87
- self._refuse_on_shared_runner()
83
+ self._refuse_where_no_subscription_runs()
88
84
  present = self._device_login.token_present(
89
85
  request.contract_id, runtime_kind=request.runtime_kind
90
86
  )
@@ -102,20 +98,22 @@ class ContractDeviceLoginActivities:
102
98
  delivered_at=delivered_at,
103
99
  )
104
100
 
105
- def _refuse_on_shared_runner(self, *, allow: bool = False) -> None:
101
+ def _refuse_where_no_subscription_runs(self) -> None:
106
102
  """Non-retryable, and typed by the rule: there is no Work Record to write Evidence
107
103
  on here, so the failure the platform's sign-in workflow receives names it.
108
104
 
109
- Only a Runner known to be the person's own signs in, except that a shared Runner
110
- may make (``allow``) a Claude Code long-lived token (local-agents 21). One whose
111
- state names no host party (registered before issue 42) is refused either way, as
112
- `choose_auth_mode` never runs a subscription on it."""
105
+ A sign-in, or a read of one, only where a Directive could run on it
106
+ (``auth_mode.runs_subscriptions``): the person's own Runner, or a shared one that
107
+ runs each Contract as its own uid. One whose state names no host party (registered
108
+ before issue 42) is refused, as `choose_auth_mode` never runs a subscription on it.
109
+ """
113
110
 
114
- if self._host_party != "user" and not (allow and is_shared_runner(self._host_party)):
111
+ if not runs_subscriptions(
112
+ self._host_party, separates_contract_uids=self._contract_isolation.can_separate_uids
113
+ ):
115
114
  raise ApplicationError(
116
- f"this Runner is hosted by {self._host_party!r}; only a person's own Runner "
117
- "signs in, a shared Runner runs API keys and Claude Code long-lived tokens "
118
- "only",
119
- type=SHARED_RUNNER_SIGN_IN_RULE,
115
+ f"this Runner is hosted by {self._host_party!r} and does not run each Contract "
116
+ "under its own uid, so no subscription runs on it and it signs nothing in",
117
+ type=SIGN_IN_NEEDS_CONTRACT_UID_RULE,
120
118
  non_retryable=True,
121
119
  )
@@ -63,7 +63,6 @@ from agentic_runner_contracts.sealed_credential import (
63
63
  SEAL_DOMAIN,
64
64
  OpenedCredential,
65
65
  SealedCredential,
66
- SealedSignInCode,
67
66
  delivery_binding,
68
67
  key_fingerprint,
69
68
  )
@@ -554,20 +553,10 @@ class SealedCredentialStream:
554
553
  if not self._awaiting:
555
554
  self._keys.retire_previous()
556
555
 
557
- def open_sign_in_code(self, sealed: SealedSignInCode) -> str | None:
558
- """A relayed sign-in code, or None when it does not open (local-agents 05).
559
-
560
- Opened with the same Recipient Key as a slot, but never held: the caller types it
561
- into the waiting CLI and drops it.
562
- """
563
-
564
- return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
565
-
566
556
  def open_oauth_code(self, sealed: SealedOAuthCode) -> str | None:
567
557
  """A relayed OAuth code, or None when it does not open (console-v2 issue 30).
568
558
 
569
- Never held, like a sign-in code: the caller spends it on the token endpoint and
570
- drops it.
559
+ Never held: the caller spends it on the token endpoint and drops it.
571
560
  """
572
561
 
573
562
  return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
@@ -24,6 +24,7 @@ import logging
24
24
  import os
25
25
  import shutil
26
26
  import signal
27
+ import sys
27
28
  import tempfile
28
29
  import time
29
30
  from collections.abc import Awaitable, Callable, Mapping, Sequence
@@ -34,6 +35,7 @@ from typing import Any, Protocol
34
35
  from uuid import UUID
35
36
 
36
37
  import httpx
38
+ from pydantic import ValidationError
37
39
  from temporalio import activity
38
40
  from temporalio.client import Client
39
41
  from temporalio.worker import (
@@ -46,6 +48,7 @@ from temporalio.worker import (
46
48
  from agentic_runner import __version__ as runner_version
47
49
  from agentic_runner.activities import RunnerRalphActivities
48
50
  from agentic_runner.attempts import AttemptRecords
51
+ from agentic_runner.auth_mode import SIGN_IN_NEEDS_CONTRACT_UID_RULE, runs_subscriptions
49
52
  from agentic_runner.child_watcher import install_stop_tolerant_child_watcher
50
53
  from agentic_runner.config import RunnerConfig
51
54
  from agentic_runner.config import load as load_config
@@ -110,7 +113,7 @@ from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
110
113
  from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime, profile_acp_executables
111
114
  from agentic_runner.workers.agent_runtime import AgentRuntime
112
115
  from agentic_runner.workers.claude_runtime import ClaudeRuntime
113
- from agentic_runner.workers.claude_sign_in import ClaudeSignIns
116
+ from agentic_runner.workers.claude_sign_in import ClaudeSignInError, ClaudeSignIns
114
117
  from agentic_runner.workers.codex_runtime import CodexRuntime
115
118
  from agentic_runner.workers.contract_device_login import ContractDeviceLogin
116
119
  from agentic_runner.workers.contract_isolation import UID_MAP_FILENAME, ContractIsolation
@@ -135,11 +138,7 @@ from agentic_runner_contracts.runner_registration import (
135
138
  ResourcePressure,
136
139
  )
137
140
  from agentic_runner_contracts.runtime_context import WorkerRuntimeContextResolver
138
- from agentic_runner_contracts.sealed_credential import (
139
- SignInCodeOutcome,
140
- SignInCodeRelay,
141
- key_fingerprint,
142
- )
141
+ from agentic_runner_contracts.sealed_credential import key_fingerprint
143
142
 
144
143
  __all__ = [
145
144
  "AGENT_TOKEN_ENV",
@@ -166,6 +165,7 @@ __all__ = [
166
165
  "run",
167
166
  "run_runner_lifecycle_hook",
168
167
  "run_worker_until_terminated",
168
+ "sign_in_claude",
169
169
  "with_profile_acp_cli_kinds",
170
170
  ]
171
171
 
@@ -657,13 +657,8 @@ class ControlPlaneStream:
657
657
  usage_windows: UsageWindows | None = None
658
658
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
659
659
  llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
660
- # Local-agents 05: the Claude Code sign-ins a relayed code is typed into.
661
- sign_ins: ClaudeSignIns | None = None
662
660
  # Console-v2 issue 30: the OAuth Connectors whose code, tokens and verifier stay here.
663
661
  oauth: OAuthConnectors | None = None
664
- # Reported once, on the beat after the relay: a lost beat loses the outcome, and the
665
- # console's own wait on the sign-in is what tells the person.
666
- _sign_in_outcomes: list[SignInCodeRelay] = field(default_factory=list)
667
662
  _delivered: set[tuple[str, str]] = field(default_factory=set)
668
663
  _offered_to_proxy: dict[tuple[str, str], str] = field(default_factory=dict)
669
664
  _last_seen: tuple[float, datetime] | None = None
@@ -773,17 +768,14 @@ class ControlPlaneStream:
773
768
  clock_skew_seconds=self._clock_skew(now),
774
769
  source_status=self.sources.statuses() if self.sources is not None else [],
775
770
  tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
776
- sign_in_codes=self._sign_in_outcomes,
777
771
  oauth_authorizations=self.oauth.take_authorizations() if self.oauth else [],
778
772
  oauth_tokens=self.oauth.uploads() if self.oauth else [],
779
773
  oauth_outcomes=self.oauth.take_outcomes() if self.oauth else [],
780
774
  )
781
- self._sign_in_outcomes = []
782
775
  ack = await self.client.heartbeat(self.state, envelope)
783
776
  if self.sources is not None:
784
777
  self.sources.assign(ack.user_sources)
785
778
  await self._deliver_transcripts(ack)
786
- await self._relay_sign_in_codes(ack)
787
779
  if self.oauth is not None:
788
780
  # Before the sealed values are applied: a Disconnect revokes with the tokens
789
781
  # this Runner holds, and the same ack may already have withdrawn their ciphertext.
@@ -836,28 +828,6 @@ class ControlPlaneStream:
836
828
  "transcript %s could not be delivered: %s", request.request_id, error
837
829
  )
838
830
 
839
- async def _relay_sign_in_codes(self, ack: HeartbeatAck) -> None:
840
- """Type each relayed code into the sign-in it names (local-agents 05).
841
-
842
- The code is opened, written to that sign-in's PTY and dropped here; nothing logs
843
- it and only its outcome travels back.
844
- """
845
-
846
- for sealed in ack.sign_in_codes:
847
- code = self.sealed.open_sign_in_code(sealed)
848
- if code is None:
849
- outcome = SignInCodeOutcome.UNOPENABLE
850
- elif self.sign_ins is None:
851
- outcome = SignInCodeOutcome.UNKNOWN
852
- else:
853
- outcome = await self.sign_ins.relay(
854
- str(sealed.contract_id), sealed.sign_in_id, code
855
- )
856
- del code
857
- self._sign_in_outcomes.append(
858
- SignInCodeRelay(sign_in_id=sealed.sign_in_id, outcome=outcome)
859
- )
860
-
861
831
  def _sync_delivered(self) -> None:
862
832
  """The resolver holds exactly what the last ack let this installation open."""
863
833
 
@@ -1021,6 +991,69 @@ async def run(
1021
991
  await readiness.close()
1022
992
 
1023
993
 
994
+ async def sign_in_claude(contract_id: str, *, method: str) -> int:
995
+ """``agentic-runner sign-in``: Claude Code's own flow for one Contract, in this terminal.
996
+
997
+ Runs with the Runner's environment (``kubectl exec``, or ``--org`` on a workstation),
998
+ so the Contract gets the uid and the harness root its Directives already run under.
999
+ Nothing reaches the control plane: the console only reads the outcome back through
1000
+ ``claude auth status`` (QTS-1448).
1001
+
1002
+ Refused up front where no Directive could run on the login (``runs_subscriptions``,
1003
+ QTS-1450), and confirmed at a prompt for a Contract this Runner holds no harness root
1004
+ for yet: a mistyped id would otherwise get a uid and a signed-in tree of its own.
1005
+ """
1006
+
1007
+ mode = isolation_mode()
1008
+ can = can_separate_uids()
1009
+ try:
1010
+ require_isolation_supported(mode, can_separate_uids=can)
1011
+ except RunnerRegistrationError as error:
1012
+ print(f"refusing to sign in ({error.reason}): {error}", file=sys.stderr)
1013
+ return 1
1014
+ control_plane = os.environ.get(CONTROL_PLANE_ENV, "").strip()
1015
+ if control_plane:
1016
+ os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
1017
+ try:
1018
+ settings = get_worker_settings()
1019
+ except ValidationError:
1020
+ print(
1021
+ "refusing to sign in: run this with the Runner's environment "
1022
+ "(`--org <org>` on a workstation, `kubectl exec` into the Runner's pod)",
1023
+ file=sys.stderr,
1024
+ )
1025
+ return 2
1026
+ state = load_state(load_config().state_dir)
1027
+ host_party = (state.host_party if state is not None else "") or None
1028
+ if not runs_subscriptions(host_party, separates_contract_uids=can):
1029
+ runner = f"hosted by {host_party!r}" if host_party else "not registered"
1030
+ print(
1031
+ f"refusing to sign in ({SIGN_IN_NEEDS_CONTRACT_UID_RULE}): this Runner is {runner} "
1032
+ "and does not run each Contract under its own uid, so no Directive would run on "
1033
+ "the login",
1034
+ file=sys.stderr,
1035
+ )
1036
+ return 1
1037
+ sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can))
1038
+ if not sign_ins.has_harness_root(contract_id) and not _confirm_new_contract(contract_id):
1039
+ print("not signed in", file=sys.stderr)
1040
+ return 1
1041
+ try:
1042
+ return await sign_ins.sign_in(contract_id, method=method)
1043
+ except ClaudeSignInError as error:
1044
+ print(f"sign-in failed: {error}", file=sys.stderr)
1045
+ return 1
1046
+
1047
+
1048
+ def _confirm_new_contract(contract_id: str) -> bool:
1049
+ answer = input(
1050
+ f"This Runner holds no Claude Code harness root for Contract {contract_id} yet. Check "
1051
+ "the id against the console: a mistyped one signs in a Contract no Directive runs as. "
1052
+ "Sign it in? [y/N] "
1053
+ )
1054
+ return answer.strip().lower() in {"y", "yes"}
1055
+
1056
+
1024
1057
  async def _serve(
1025
1058
  *,
1026
1059
  config: RunnerConfig,
@@ -1157,7 +1190,6 @@ async def _serve_registered(
1157
1190
  messages = MessageStore(state_dir / MESSAGES_DIR)
1158
1191
  stream.messages = messages
1159
1192
  sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can_change_uid))
1160
- stream.sign_ins = sign_ins
1161
1193
  stream.oauth = OAuthConnectors(
1162
1194
  sealed=stream.sealed, provider=HttpOAuthProvider(), credentials=credentials
1163
1195
  )
@@ -1243,7 +1275,6 @@ async def _serve_registered(
1243
1275
  task.cancel()
1244
1276
  with contextlib.suppress(asyncio.CancelledError):
1245
1277
  await task
1246
- await sign_ins.close()
1247
1278
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
1248
1279
  await _report_stop(link, lifecycle)
1249
1280
  return 1 if revoked.is_set() else 0
@@ -1346,13 +1377,12 @@ def build_usage_windows(
1346
1377
  isolation: ContractIsolation,
1347
1378
  in_flight: DirectivesInFlight,
1348
1379
  ) -> UsageWindows | None:
1349
- """A probe per served harness, or None off a user-hosted Runner (local-agents 10).
1350
-
1351
- Subscriptions follow the person (local-agents 04): only a user-hosted Runner runs one,
1352
- so only there is a usage window anyone's to read.
1380
+ """A probe per served harness, or None on a Runner that runs no subscription
1381
+ (local-agents 10): only where one can run is there a usage window anyone's to read
1382
+ (``auth_mode.runs_subscriptions``, QTS-1450).
1353
1383
  """
1354
1384
 
1355
- if host_party != "user":
1385
+ if not runs_subscriptions(host_party, separates_contract_uids=isolation.can_separate_uids):
1356
1386
  return None
1357
1387
  probes: dict[str, UsageWindowProbe] = {
1358
1388
  "codex_cli": CodexRateLimitsProbe(),