agentic-runner 3.13.0__tar.gz → 3.14.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 (76) hide show
  1. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/cli.py +43 -0
  4. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/device_login_activities.py +6 -10
  5. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/sealed_box.py +1 -12
  6. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/service.py +41 -38
  7. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +7 -4
  8. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/claude_sign_in.py +145 -252
  9. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/contract_device_login.py +3 -17
  10. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/.gitignore +0 -0
  11. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/LICENSE +0 -0
  12. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/README.md +0 -0
  13. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/pyproject.toml +0 -0
  14. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/activities.py +0 -0
  15. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/attempts.py +0 -0
  16. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/auth_mode.py +0 -0
  17. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/build.py +0 -0
  18. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/callback.py +0 -0
  19. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/child_watcher.py +0 -0
  20. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/cli_floor.py +0 -0
  21. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/config.py +0 -0
  22. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/credentials.py +0 -0
  23. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/egress.py +0 -0
  24. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/harness_self_test.py +0 -0
  25. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/heartbeat_link.py +0 -0
  26. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/hooks.py +0 -0
  27. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/host_store.py +0 -0
  28. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/__init__.py +0 -0
  29. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  30. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  31. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  32. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  33. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  34. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  35. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  36. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  37. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  38. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  39. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  40. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/lifecycle.py +0 -0
  41. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/llm_proxy.py +0 -0
  42. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/mcp.py +0 -0
  43. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/message_store.py +0 -0
  44. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/oauth_connectors.py +0 -0
  45. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/private_state.py +0 -0
  46. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/py.typed +0 -0
  47. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  48. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/registration.py +0 -0
  49. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/runtime/__init__.py +0 -0
  50. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  51. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/skill.py +0 -0
  52. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/testing/__init__.py +0 -0
  53. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/testing/__main__.py +0 -0
  54. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/testing/control_plane.py +0 -0
  55. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/testing/plugin.py +0 -0
  56. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  57. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/testing/workflow.py +0 -0
  58. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/tiny_http.py +0 -0
  59. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/triage_activities.py +0 -0
  60. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/usage_windows.py +0 -0
  61. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/user_sources.py +0 -0
  62. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/__init__.py +0 -0
  63. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  64. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  65. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  66. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  67. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  68. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/command_policy.py +0 -0
  69. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  70. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  71. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  72. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  73. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  74. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/settings.py +0 -0
  75. {agentic_runner-3.13.0 → agentic_runner-3.14.0}/src/agentic_runner/workers/skills.py +0 -0
  76. {agentic_runner-3.13.0 → agentic_runner-3.14.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.14.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.14.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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)
@@ -14,7 +14,6 @@ from temporalio import activity
14
14
  from temporalio.exceptions import ApplicationError
15
15
 
16
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
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 (
@@ -37,8 +36,9 @@ class ContractDeviceLoginActivities:
37
36
  host_party: str | None = None,
38
37
  ) -> None:
39
38
  self._contract_isolation = contract_isolation
40
- # 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).
39
+ # The party this process was registered as hosted by. A shared Runner starts no
40
+ # device login and reports only Claude Code's sign-in (local-agents 04, amended by
41
+ # 21); a person signs 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_on_shared_runner()
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")
@@ -107,7 +103,7 @@ class ContractDeviceLoginActivities:
107
103
  on here, so the failure the platform's sign-in workflow receives names it.
108
104
 
109
105
  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
106
+ may report (``allow``) a Claude Code long-lived token (local-agents 21). One whose
111
107
  state names no host party (registered before issue 42) is refused either way, as
112
108
  `choose_auth_mode` never runs a subscription on it."""
113
109
 
@@ -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 (
@@ -110,7 +112,7 @@ from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
110
112
  from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime, profile_acp_executables
111
113
  from agentic_runner.workers.agent_runtime import AgentRuntime
112
114
  from agentic_runner.workers.claude_runtime import ClaudeRuntime
113
- from agentic_runner.workers.claude_sign_in import ClaudeSignIns
115
+ from agentic_runner.workers.claude_sign_in import ClaudeSignInError, ClaudeSignIns
114
116
  from agentic_runner.workers.codex_runtime import CodexRuntime
115
117
  from agentic_runner.workers.contract_device_login import ContractDeviceLogin
116
118
  from agentic_runner.workers.contract_isolation import UID_MAP_FILENAME, ContractIsolation
@@ -135,11 +137,7 @@ from agentic_runner_contracts.runner_registration import (
135
137
  ResourcePressure,
136
138
  )
137
139
  from agentic_runner_contracts.runtime_context import WorkerRuntimeContextResolver
138
- from agentic_runner_contracts.sealed_credential import (
139
- SignInCodeOutcome,
140
- SignInCodeRelay,
141
- key_fingerprint,
142
- )
140
+ from agentic_runner_contracts.sealed_credential import key_fingerprint
143
141
 
144
142
  __all__ = [
145
143
  "AGENT_TOKEN_ENV",
@@ -166,6 +164,7 @@ __all__ = [
166
164
  "run",
167
165
  "run_runner_lifecycle_hook",
168
166
  "run_worker_until_terminated",
167
+ "sign_in_claude",
169
168
  "with_profile_acp_cli_kinds",
170
169
  ]
171
170
 
@@ -657,13 +656,8 @@ class ControlPlaneStream:
657
656
  usage_windows: UsageWindows | None = None
658
657
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
659
658
  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
659
  # Console-v2 issue 30: the OAuth Connectors whose code, tokens and verifier stay here.
663
660
  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
661
  _delivered: set[tuple[str, str]] = field(default_factory=set)
668
662
  _offered_to_proxy: dict[tuple[str, str], str] = field(default_factory=dict)
669
663
  _last_seen: tuple[float, datetime] | None = None
@@ -773,17 +767,14 @@ class ControlPlaneStream:
773
767
  clock_skew_seconds=self._clock_skew(now),
774
768
  source_status=self.sources.statuses() if self.sources is not None else [],
775
769
  tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
776
- sign_in_codes=self._sign_in_outcomes,
777
770
  oauth_authorizations=self.oauth.take_authorizations() if self.oauth else [],
778
771
  oauth_tokens=self.oauth.uploads() if self.oauth else [],
779
772
  oauth_outcomes=self.oauth.take_outcomes() if self.oauth else [],
780
773
  )
781
- self._sign_in_outcomes = []
782
774
  ack = await self.client.heartbeat(self.state, envelope)
783
775
  if self.sources is not None:
784
776
  self.sources.assign(ack.user_sources)
785
777
  await self._deliver_transcripts(ack)
786
- await self._relay_sign_in_codes(ack)
787
778
  if self.oauth is not None:
788
779
  # Before the sealed values are applied: a Disconnect revokes with the tokens
789
780
  # this Runner holds, and the same ack may already have withdrawn their ciphertext.
@@ -836,28 +827,6 @@ class ControlPlaneStream:
836
827
  "transcript %s could not be delivered: %s", request.request_id, error
837
828
  )
838
829
 
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
830
  def _sync_delivered(self) -> None:
862
831
  """The resolver holds exactly what the last ack let this installation open."""
863
832
 
@@ -1021,6 +990,42 @@ async def run(
1021
990
  await readiness.close()
1022
991
 
1023
992
 
993
+ async def sign_in_claude(contract_id: str, *, method: str) -> int:
994
+ """``agentic-runner sign-in``: Claude Code's own flow for one Contract, in this terminal.
995
+
996
+ Runs with the Runner's environment (``kubectl exec``, or ``--org`` on a workstation),
997
+ so the Contract gets the uid and the harness root its Directives already run under.
998
+ Nothing reaches the control plane: the console only reads the outcome back through
999
+ ``claude auth status`` (QTS-1448).
1000
+ """
1001
+
1002
+ mode = isolation_mode()
1003
+ can = can_separate_uids()
1004
+ try:
1005
+ require_isolation_supported(mode, can_separate_uids=can)
1006
+ except RunnerRegistrationError as error:
1007
+ print(f"refusing to sign in ({error.reason}): {error}", file=sys.stderr)
1008
+ return 1
1009
+ control_plane = os.environ.get(CONTROL_PLANE_ENV, "").strip()
1010
+ if control_plane:
1011
+ os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
1012
+ try:
1013
+ settings = get_worker_settings()
1014
+ except ValidationError:
1015
+ print(
1016
+ "refusing to sign in: run this with the Runner's environment "
1017
+ "(`--org <org>` on a workstation, `kubectl exec` into the Runner's pod)",
1018
+ file=sys.stderr,
1019
+ )
1020
+ return 2
1021
+ sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can))
1022
+ try:
1023
+ return await sign_ins.sign_in(contract_id, method=method)
1024
+ except ClaudeSignInError as error:
1025
+ print(f"sign-in failed: {error}", file=sys.stderr)
1026
+ return 1
1027
+
1028
+
1024
1029
  async def _serve(
1025
1030
  *,
1026
1031
  config: RunnerConfig,
@@ -1157,7 +1162,6 @@ async def _serve_registered(
1157
1162
  messages = MessageStore(state_dir / MESSAGES_DIR)
1158
1163
  stream.messages = messages
1159
1164
  sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can_change_uid))
1160
- stream.sign_ins = sign_ins
1161
1165
  stream.oauth = OAuthConnectors(
1162
1166
  sealed=stream.sealed, provider=HttpOAuthProvider(), credentials=credentials
1163
1167
  )
@@ -1243,7 +1247,6 @@ async def _serve_registered(
1243
1247
  task.cancel()
1244
1248
  with contextlib.suppress(asyncio.CancelledError):
1245
1249
  await task
1246
- await sign_ins.close()
1247
1250
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
1248
1251
  await _report_stop(link, lifecycle)
1249
1252
  return 1 if revoked.is_set() else 0
@@ -120,11 +120,14 @@ whose LLM key or subscription the Agents spend. Ask which of the two the Contrac
120
120
  For Docker and Kubernetes the value is delivered from the console, sealed to the Runner:
121
121
  https://github.com/qtsone/agentic-runner/blob/main/docs/helm.md#the-recipient-key.
122
122
  - **A Codex or Claude Code subscription.** This works only on a Runner the person hosts
123
- themselves. Send them to the Contract's page in the console and its **Connect** button:
124
- the console opens the vendor's sign-in in their browser and the Runner finishes it. See
123
+ themselves. For Codex, send them to the Contract's page in the console and its
124
+ **Connect** button. For Claude Code, ask the person to run, in their own terminal, the
125
+ sign-in line for their way of installing: `agentic-runner sign-in --org <org> --contract
126
+ <contract-id>` on this machine, or the `docker exec -it` / `kubectl exec -it` line in
127
+ docker.md or helm.md. See
125
128
  https://github.com/qtsone/agentic-runner/blob/main/docs/workstation.md#sign-in-to-codex-and-claude-code.
126
- You have no sign-in of your own to run, and you do not run `codex login` or
127
- `claude auth login`.
129
+ The sign-in is the person's own, with their own Anthropic account: you never run it, and
130
+ you do not run `codex login` or `claude auth login`.
128
131
 
129
132
  ### 5. Wait for the Runner to come online
130
133
 
@@ -1,4 +1,9 @@
1
- """Claude Code's in-place sign-in for one Contract (local-agents 05, PRD decision 13).
1
+ """Claude Code's sign-in for one Contract, run by the person on the Runner (QTS-1448).
2
+
3
+ ``agentic-runner sign-in`` runs Anthropic's own CLI flow, unmodified, as the Contract's
4
+ uid in its harness root and attached to the caller's terminal. No sign-in code, token or
5
+ session passes through the control plane: the QTS-1349 decision (controls 1 and 5)
6
+ retired the sealed sign-in-code relay local-agents 05 built here.
2
7
 
3
8
  Two methods, both made in the Contract's own harness root and neither ever delivered:
4
9
 
@@ -9,14 +14,9 @@ Two methods, both made in the Contract's own harness root and neither ever deliv
9
14
  * ``claude_ai``: ``claude auth login --claudeai``, a refreshing login the CLI keeps itself
10
15
  -- ``.credentials.json`` on Linux, a Keychain item on macOS.
11
16
 
12
- Both are interactive paste-code flows with no device code (claude 2.1.295: S256 PKCE, the
13
- CLI waits at "Paste code here if prompted >"), so the CLI runs on a pseudo-terminal and
14
- the code the browser shows the person comes back sealed on the heartbeat ack
15
- (:meth:`ClaudeSignIns.relay`). The PKCE verifier never leaves the CLI's process, so the
16
- code alone signs nobody in.
17
-
18
- A waiting sign-in is the one thing here held in the Runner's process: the PTY is the only
19
- way to reach the CLI. A restart closes it and the CLI exits; the person starts again.
17
+ The CLI runs on a pseudo-terminal this process relays to the caller's own, byte for byte:
18
+ ``setup-token`` prints the token to its terminal and nowhere else, so the relay is the
19
+ only way to read it. The person sees and types exactly what the CLI shows and reads.
20
20
 
21
21
  macOS (the 2026-10-08 spike): Claude saves a ``claude_ai`` login with the ``security`` CLI
22
22
  into the default keychain of the ``HOME`` it is given, and a Contract's sandbox ``HOME``
@@ -31,7 +31,6 @@ import asyncio
31
31
  import contextlib
32
32
  import fcntl
33
33
  import json
34
- import logging
35
34
  import os
36
35
  import pty
37
36
  import re
@@ -40,15 +39,14 @@ import signal
40
39
  import struct
41
40
  import sys
42
41
  import termios
43
- from collections.abc import Callable, Sequence
44
- from dataclasses import dataclass, field
45
- from datetime import UTC, datetime, timedelta
42
+ import tty
43
+ from collections.abc import Iterator, Sequence
44
+ from dataclasses import dataclass
46
45
  from pathlib import Path
47
46
  from typing import Final
48
47
 
49
48
  from agentic_runner.workers._runtime_support import run_subprocess_exec
50
49
  from agentic_runner.workers.contract_isolation import ContractIsolation, DirectiveSandbox
51
- from agentic_runner_contracts.sealed_credential import SignInCodeOutcome
52
50
 
53
51
  __all__ = [
54
52
  "CLAUDE_AI",
@@ -57,9 +55,9 @@ __all__ = [
57
55
  "KEYCHAIN_PASSWORD_FILE",
58
56
  "OAUTH_TOKEN",
59
57
  "OAUTH_TOKEN_FILE",
58
+ "SIGN_IN_METHODS",
60
59
  "ClaudeAuthStatus",
61
60
  "ClaudeSignInError",
62
- "ClaudeSignInPrompt",
63
61
  "ClaudeSignIns",
64
62
  "UnknownSignInMethodError",
65
63
  "launcher_argv",
@@ -73,6 +71,7 @@ CLAUDE_SIGN_IN_ARGV: Final[dict[str, tuple[str, ...]]] = {
73
71
  OAUTH_TOKEN: ("claude", "setup-token"),
74
72
  CLAUDE_AI: ("claude", "auth", "login", "--claudeai"),
75
73
  }
74
+ SIGN_IN_METHODS: Final[tuple[str, ...]] = tuple(CLAUDE_SIGN_IN_ARGV)
76
75
  _STATUS_ARGV: Final[tuple[str, ...]] = ("claude", "auth", "status", "--json")
77
76
 
78
77
  OAUTH_TOKEN_FILE: Final[str] = "oauth-token"
@@ -99,24 +98,16 @@ export CLAUDE_CODE_SUBPROCESS_ENV_SCRUB
99
98
  exec "$@"
100
99
  """
101
100
 
102
- # A relayed code expires after this, or when the waiting CLI exits, whichever is first;
103
- # the CLI's process group is killed at the same moment.
104
- SIGN_IN_WINDOW: Final[timedelta] = timedelta(minutes=10)
105
- # Paperclip's setup-token-runner.ts:44-60: Ink reads a paste and its Enter as one chunk
106
- # and drops the Enter, so the newline goes in a separate write a beat later.
107
- _ENTER_DELAY_SECONDS: Final[float] = 0.15
108
- # Ink wraps the authorize URL and the token at the terminal width; one line each keeps
109
- # them whole.
101
+ # Ink wraps the authorize URL and the token at the width it is told; one line each keeps
102
+ # the token whole for `_printed_token`. The caller's terminal soft-wraps what it shows,
103
+ # which also leaves the URL copyable in one piece.
110
104
  _PTY_COLUMNS: Final[int] = 1000
111
105
  _PTY_ROWS: Final[int] = 50
112
- _DEFAULT_PROMPT_TIMEOUT_SECONDS: Final[float] = 30.0
113
106
  _STATUS_TIMEOUT_SECONDS: Final[int] = 30
114
107
  _OUTPUT_LIMIT_BYTES: Final[int] = 65_536
115
108
 
116
- _logger = logging.getLogger(__name__)
117
-
118
109
  # Runs as the Contract's uid, never the Runner's: the Contract owns every directory down to
119
- # its harness root and can swap one for a symlink while a sign-in waits. The Runner, with
110
+ # its harness root and can swap one for a symlink while a sign-in runs. The Runner, with
120
111
  # CAP_CHOWN and CAP_DAC_OVERRIDE, would follow it into another Contract's root; as the
121
112
  # Contract's uid the write or unlink reaches only what that Contract already could.
122
113
  # ``value`` None removes the file.
@@ -146,31 +137,18 @@ except BaseException:
146
137
  raise
147
138
  """
148
139
 
149
- # The URL arrives as an OSC 8 hyperlink whose target is whole even when its visible text
150
- # is split; the plain form is the fallback for a CLI that prints it bare.
151
- _OSC8_URL: Final[re.Pattern[str]] = re.compile(r"\x1b\]8;[^;\x07]*;(https://[^\x07\x1b]+)")
152
- _PLAIN_URL: Final[re.Pattern[str]] = re.compile(r"https://\S+/oauth/authorize\?[^\s\x07\x1b]+")
153
140
  _ANSI: Final[re.Pattern[str]] = re.compile(r"\x1b(?:\][^\x07]*\x07|\[[0-9;?>]*[A-Za-z]|[78])")
154
141
  _TOKEN: Final[re.Pattern[str]] = re.compile(r"sk-ant-oat\d\d-[A-Za-z0-9_-]{20,}")
155
- # What a browser shows the person to paste back: base64url, with `#` joining the state.
156
- _CODE_SHAPE: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9_\-#.~]{8,512}$")
157
142
 
158
143
 
159
144
  class ClaudeSignInError(RuntimeError):
160
- """The CLI never printed a usable sign-in prompt, or its status could not be read."""
145
+ """The CLI did not start, its status could not be read, or it printed no token."""
161
146
 
162
147
 
163
148
  class UnknownSignInMethodError(ValueError):
164
149
  """A ``method`` this Runner has no Claude Code sign-in command for."""
165
150
 
166
151
 
167
- @dataclass(frozen=True, slots=True)
168
- class ClaudeSignInPrompt:
169
- sign_in_id: str
170
- verification_uri: str
171
- expires_at: datetime
172
-
173
-
174
152
  @dataclass(frozen=True, slots=True)
175
153
  class ClaudeAuthStatus:
176
154
  """``claude auth status --json``'s three fields, and nothing else it reports."""
@@ -180,22 +158,6 @@ class ClaudeAuthStatus:
180
158
  subscription_type: str | None
181
159
 
182
160
 
183
- @dataclass(eq=False)
184
- class _WaitingSignIn:
185
- sign_in_id: str
186
- contract_id: str
187
- method: str
188
- harness_root: Path
189
- uid: int | None
190
- process: asyncio.subprocess.Process
191
- master_fd: int
192
- deadline: float
193
- output: bytearray = field(default_factory=bytearray)
194
- changed: asyncio.Event = field(default_factory=asyncio.Event)
195
- exited: asyncio.Event = field(default_factory=asyncio.Event)
196
- ended: bool = False
197
-
198
-
199
161
  def launcher_argv(harness_root: Path, argv: Sequence[str]) -> list[str]:
200
162
  """``argv`` behind the launcher that hands it this harness root's login."""
201
163
 
@@ -203,7 +165,7 @@ def launcher_argv(harness_root: Path, argv: Sequence[str]) -> list[str]:
203
165
 
204
166
 
205
167
  class ClaudeSignIns:
206
- """Every Claude Code sign-in this Runner has started and is still waiting on."""
168
+ """Claude Code's sign-in and sign-in status for each Contract's harness root."""
207
169
 
208
170
  def __init__(
209
171
  self,
@@ -211,80 +173,48 @@ class ClaudeSignIns:
211
173
  *,
212
174
  argv_by_method: dict[str, tuple[str, ...]] | None = None,
213
175
  status_argv: Sequence[str] = _STATUS_ARGV,
214
- prompt_timeout_seconds: float = _DEFAULT_PROMPT_TIMEOUT_SECONDS,
215
- window: timedelta = SIGN_IN_WINDOW,
216
176
  platform: str = sys.platform,
217
- clock: Callable[[], float] | None = None,
218
177
  ) -> None:
219
178
  self._isolation = isolation
220
179
  self._argv_by_method = argv_by_method or CLAUDE_SIGN_IN_ARGV
221
180
  self._status_argv = list(status_argv)
222
- self._prompt_timeout_seconds = prompt_timeout_seconds
223
- self._window = window
224
181
  self._platform = platform
225
- self._clock = clock
226
- self._waiting: dict[str, _WaitingSignIn] = {}
227
- self._watchers: set[asyncio.Task[None]] = set()
228
-
229
- def _now(self) -> float:
230
- return self._clock() if self._clock is not None else asyncio.get_running_loop().time()
231
182
 
232
- @property
233
- def waiting(self) -> int:
234
- return len(self._waiting)
183
+ async def sign_in(
184
+ self, contract_id: str, *, method: str = OAUTH_TOKEN, stdin_fd: int = 0, stdout_fd: int = 1
185
+ ) -> int:
186
+ """Run the method's CLI as this Contract's uid, attached to the caller's terminal
187
+ (``stdin_fd``/``stdout_fd``); keep what it signed in. Returns the CLI's exit code."""
235
188
 
236
- async def start(self, contract_id: str, *, method: str | None) -> ClaudeSignInPrompt:
237
- """Spawn the method's CLI as this Contract's uid; return once it prints its URL."""
238
-
239
- method = method or OAUTH_TOKEN
240
189
  argv = self._argv_by_method.get(method)
241
190
  if argv is None:
242
191
  raise UnknownSignInMethodError(f"no Claude Code sign-in method {method!r}")
243
192
  sandbox = self._isolation.sandbox(contract_id, runtime_kind=RUNTIME_KIND)
244
- # One sign-in per Contract: a second start supersedes the first, whose code the
245
- # person can no longer use anyway.
246
- for waiting in [w for w in self._waiting.values() if w.contract_id == contract_id]:
247
- await self._end(waiting)
248
193
  if method == CLAUDE_AI and self._platform == "darwin":
249
194
  await self._ensure_keychain(sandbox)
250
- session = await self._spawn(contract_id, method, argv, sandbox)
195
+ output = bytearray()
251
196
  try:
252
- url = await self._await_prompt(session)
253
- except BaseException:
254
- await self._end(session)
255
- raise
256
- # The relay rests on PKCE: without a verifier held by the CLI, a relayed code
257
- # would be a usable credential in transit (local-agents 05, item 1).
258
- if "code_challenge_method=S256" not in url:
259
- await self._end(session)
260
- raise ClaudeSignInError("the sign-in URL carries no S256 code challenge")
261
- self._waiting[session.sign_in_id] = session
262
- watcher = asyncio.ensure_future(self._watch(session))
263
- self._watchers.add(watcher)
264
- watcher.add_done_callback(self._watchers.discard)
265
- return ClaudeSignInPrompt(
266
- sign_in_id=session.sign_in_id,
267
- verification_uri=url,
268
- expires_at=datetime.now(UTC) + self._window,
269
- )
270
-
271
- async def relay(self, contract_id: str, sign_in_id: str, code: str) -> SignInCodeOutcome:
272
- """Type one relayed code into the sign-in it names, or say why not. Never retried."""
273
-
274
- session = self._waiting.get(sign_in_id)
275
- if session is None or session.contract_id != contract_id:
276
- return SignInCodeOutcome.UNKNOWN
277
- if session.exited.is_set() or self._now() >= session.deadline:
278
- return SignInCodeOutcome.EXPIRED
279
- if not _CODE_SHAPE.fullmatch(code):
280
- # A newline or a control character would be keystrokes, not a code.
281
- return SignInCodeOutcome.UNOPENABLE
282
- os.write(session.master_fd, code.encode("ascii"))
283
- await asyncio.sleep(_ENTER_DELAY_SECONDS)
284
- if session.exited.is_set():
285
- return SignInCodeOutcome.EXPIRED
286
- os.write(session.master_fd, b"\r")
287
- return SignInCodeOutcome.WRITTEN
197
+ returncode = await _run_attached(
198
+ launcher_argv(sandbox.harness_config_dir, argv),
199
+ sandbox,
200
+ output=output,
201
+ stdin_fd=stdin_fd,
202
+ stdout_fd=stdout_fd,
203
+ )
204
+ token_file = sandbox.harness_config_dir / OAUTH_TOKEN_FILE
205
+ if method == OAUTH_TOKEN:
206
+ token = _printed_token(output.decode("utf-8", errors="replace"))
207
+ if token is not None:
208
+ await _write_private(token_file, token, sandbox.uid)
209
+ elif returncode == 0:
210
+ raise ClaudeSignInError("claude setup-token exited 0 but printed no token")
211
+ elif returncode == 0:
212
+ # A finished `claude_ai` sign-in replaces a long-lived token, which the
213
+ # launcher would otherwise go on preferring.
214
+ await _write_private(token_file, None, sandbox.uid)
215
+ finally:
216
+ output.clear()
217
+ return returncode
288
218
 
289
219
  async def status(self, contract_id: str) -> ClaudeAuthStatus:
290
220
  """Ask the CLI, as the Contract's uid, whether it is signed in.
@@ -322,134 +252,6 @@ class ClaudeSignIns:
322
252
  subscription_type=plan if isinstance(plan, str) else None,
323
253
  )
324
254
 
325
- async def close(self) -> None:
326
- for session in list(self._waiting.values()):
327
- await self._end(session)
328
-
329
- async def _spawn(
330
- self,
331
- contract_id: str,
332
- method: str,
333
- argv: tuple[str, ...],
334
- sandbox: DirectiveSandbox,
335
- ) -> _WaitingSignIn:
336
- master_fd, slave_fd = pty.openpty()
337
- try:
338
- fcntl.ioctl(
339
- slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", _PTY_ROWS, _PTY_COLUMNS, 0, 0)
340
- )
341
- os.set_blocking(master_fd, False)
342
- try:
343
- process = await asyncio.create_subprocess_exec(
344
- *launcher_argv(sandbox.harness_config_dir, argv),
345
- cwd=sandbox.home_dir,
346
- env=_harness_env(sandbox) | {"TERM": "xterm-256color"},
347
- stdin=slave_fd,
348
- stdout=slave_fd,
349
- stderr=slave_fd,
350
- start_new_session=True,
351
- **sandbox.spawn_kwargs(),
352
- )
353
- except OSError as error:
354
- raise ClaudeSignInError(
355
- f"claude failed to start: {error.__class__.__name__}"
356
- ) from None
357
- except BaseException:
358
- os.close(master_fd)
359
- raise
360
- finally:
361
- os.close(slave_fd)
362
- session = _WaitingSignIn(
363
- sign_in_id=f"si_{secrets.token_hex(16)}",
364
- contract_id=contract_id,
365
- method=method,
366
- harness_root=sandbox.harness_config_dir,
367
- uid=sandbox.uid,
368
- process=process,
369
- master_fd=master_fd,
370
- deadline=self._now() + self._window.total_seconds(),
371
- )
372
- asyncio.get_running_loop().add_reader(master_fd, self._read, session)
373
- return session
374
-
375
- def _read(self, session: _WaitingSignIn) -> None:
376
- try:
377
- chunk = os.read(session.master_fd, 4096)
378
- except BlockingIOError:
379
- return
380
- except OSError:
381
- # EIO: every holder of the slave side is gone, i.e. the CLI exited.
382
- chunk = b""
383
- if not chunk:
384
- asyncio.get_running_loop().remove_reader(session.master_fd)
385
- session.exited.set()
386
- session.changed.set()
387
- return
388
- _keep_tail(session.output, chunk)
389
- session.changed.set()
390
-
391
- async def _await_prompt(self, session: _WaitingSignIn) -> str:
392
- async with asyncio.timeout(self._prompt_timeout_seconds):
393
- while True:
394
- url = _authorize_url(session.output.decode("utf-8", errors="replace"))
395
- if url is not None:
396
- return url
397
- if session.exited.is_set():
398
- code = await session.process.wait()
399
- raise ClaudeSignInError(
400
- f"claude exited before printing its sign-in URL (code {code})"
401
- )
402
- session.changed.clear()
403
- await session.changed.wait()
404
-
405
- async def _watch(self, session: _WaitingSignIn) -> None:
406
- remaining = max(0.0, session.deadline - self._now())
407
- with contextlib.suppress(TimeoutError):
408
- await asyncio.wait_for(session.exited.wait(), timeout=remaining)
409
- await self._end(session)
410
-
411
- async def _end(self, session: _WaitingSignIn) -> None:
412
- """Kill the group if it is still waiting, keep what it signed in, forget the rest."""
413
-
414
- if session.ended:
415
- return
416
- session.ended = True
417
- loop = asyncio.get_running_loop()
418
- if not session.exited.is_set():
419
- loop.remove_reader(session.master_fd)
420
- with contextlib.suppress(ProcessLookupError, PermissionError):
421
- os.killpg(session.process.pid, signal.SIGKILL)
422
- session.exited.set()
423
- with contextlib.suppress(TimeoutError):
424
- await asyncio.wait_for(session.process.wait(), timeout=2)
425
- with contextlib.suppress(OSError):
426
- # The CLI's last words can still be in the master's buffer after EOF.
427
- while chunk := os.read(session.master_fd, 4096):
428
- _keep_tail(session.output, chunk)
429
- with contextlib.suppress(OSError):
430
- os.close(session.master_fd)
431
- session.master_fd = -1
432
- token_file = session.harness_root / OAUTH_TOKEN_FILE
433
- try:
434
- if session.method == OAUTH_TOKEN:
435
- token = _printed_token(session.output.decode("utf-8", errors="replace"))
436
- if token is not None:
437
- await _write_private(token_file, token, session.uid)
438
- elif session.process.returncode == 0:
439
- raise ClaudeSignInError("claude setup-token exited 0 but printed no token")
440
- elif session.process.returncode == 0:
441
- # A finished `claude_ai` sign-in replaces a long-lived token, which the
442
- # launcher would otherwise go on preferring.
443
- await _write_private(token_file, None, session.uid)
444
- except ClaudeSignInError as error:
445
- # Nobody awaits a sign-in's end; the person sees it as still signed out.
446
- _logger.error("Claude Code sign-in %s did not complete: %s", session.sign_in_id, error)
447
- finally:
448
- session.output.clear()
449
- # Last, so `waiting` falls to zero only once the token is on disk.
450
- if self._waiting.get(session.sign_in_id) is session:
451
- del self._waiting[session.sign_in_id]
452
-
453
255
  async def _ensure_keychain(self, sandbox: DirectiveSandbox) -> None:
454
256
  """A per-Contract keychain, the default of the sandbox ``HOME`` (macOS only)."""
455
257
 
@@ -483,6 +285,104 @@ class ClaudeSignIns:
483
285
  raise ClaudeSignInError(f"security {argv[1]} failed (exit {result.exit_code})")
484
286
 
485
287
 
288
+ async def _run_attached(
289
+ argv: Sequence[str],
290
+ sandbox: DirectiveSandbox,
291
+ *,
292
+ output: bytearray,
293
+ stdin_fd: int,
294
+ stdout_fd: int,
295
+ ) -> int:
296
+ """Run ``argv`` on a pseudo-terminal relayed to the caller's; its output's tail lands
297
+ in ``output``. Returns once the CLI has exited and its last output is relayed."""
298
+
299
+ master_fd, slave_fd = pty.openpty()
300
+ try:
301
+ rows = os.get_terminal_size(stdout_fd).lines if os.isatty(stdout_fd) else _PTY_ROWS
302
+ fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, _PTY_COLUMNS, 0, 0))
303
+ os.set_blocking(master_fd, False)
304
+ try:
305
+ process = await asyncio.create_subprocess_exec(
306
+ *argv,
307
+ cwd=sandbox.home_dir,
308
+ env=_harness_env(sandbox) | {"TERM": os.environ.get("TERM") or "xterm-256color"},
309
+ stdin=slave_fd,
310
+ stdout=slave_fd,
311
+ stderr=slave_fd,
312
+ start_new_session=True,
313
+ **sandbox.spawn_kwargs(),
314
+ )
315
+ except OSError as error:
316
+ raise ClaudeSignInError(f"claude failed to start: {error.__class__.__name__}") from None
317
+ except BaseException:
318
+ os.close(master_fd)
319
+ raise
320
+ finally:
321
+ os.close(slave_fd)
322
+
323
+ loop = asyncio.get_running_loop()
324
+ exited = asyncio.Event()
325
+
326
+ def from_cli() -> None:
327
+ try:
328
+ chunk = os.read(master_fd, 4096)
329
+ except BlockingIOError:
330
+ return
331
+ except OSError:
332
+ # EIO: every holder of the slave side is gone, i.e. the CLI exited.
333
+ chunk = b""
334
+ if not chunk:
335
+ loop.remove_reader(master_fd)
336
+ exited.set()
337
+ return
338
+ _write_all(stdout_fd, chunk)
339
+ _keep_tail(output, chunk)
340
+
341
+ def from_person() -> None:
342
+ typed = os.read(stdin_fd, 1024)
343
+ if not typed:
344
+ loop.remove_reader(stdin_fd)
345
+ return
346
+ os.write(master_fd, typed)
347
+
348
+ loop.add_reader(master_fd, from_cli)
349
+ loop.add_reader(stdin_fd, from_person)
350
+ try:
351
+ with _raw(stdin_fd):
352
+ await exited.wait()
353
+ return await process.wait()
354
+ except BaseException:
355
+ with contextlib.suppress(ProcessLookupError, PermissionError):
356
+ os.killpg(process.pid, signal.SIGKILL)
357
+ raise
358
+ finally:
359
+ loop.remove_reader(master_fd)
360
+ loop.remove_reader(stdin_fd)
361
+ os.close(master_fd)
362
+
363
+
364
+ @contextlib.contextmanager
365
+ def _raw(fd: int) -> Iterator[None]:
366
+ """The caller's terminal in raw mode, so each key reaches the CLI as the CLI's own
367
+ terminal would deliver it (Ink reads keys, not lines), and restored after."""
368
+
369
+ if not os.isatty(fd):
370
+ yield
371
+ return
372
+ saved = termios.tcgetattr(fd)
373
+ tty.setraw(fd)
374
+ try:
375
+ yield
376
+ finally:
377
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
378
+
379
+
380
+ def _write_all(fd: int, data: bytes) -> None:
381
+ view = memoryview(data)
382
+ while view:
383
+ view = view[os.write(fd, view) :]
384
+
385
+
486
386
  def _harness_env(sandbox: DirectiveSandbox) -> dict[str, str]:
487
387
  return {
488
388
  "HOME": str(sandbox.home_dir),
@@ -492,13 +392,6 @@ def _harness_env(sandbox: DirectiveSandbox) -> dict[str, str]:
492
392
  }
493
393
 
494
394
 
495
- def _authorize_url(text: str) -> str | None:
496
- if hyperlink := _OSC8_URL.search(text):
497
- return hyperlink.group(1)
498
- plain = _PLAIN_URL.search(_ANSI.sub("", text))
499
- return plain.group(0) if plain else None
500
-
501
-
502
395
  def _printed_token(text: str) -> str | None:
503
396
  match = _TOKEN.search(_ANSI.sub("", text))
504
397
  return match.group(0) if match else None
@@ -54,8 +54,8 @@ _TOKEN_FILES_BY_RUNTIME: Final[dict[str, tuple[str, ...]]] = {
54
54
  }
55
55
 
56
56
  # Codex's non-interactive device-code flow (research/29 §1.5, "Headless login is
57
- # first-class"). Claude Code's is an interactive paste-code flow on a pseudo-terminal
58
- # instead: ``claude_sign_in.CLAUDE_SIGN_IN_ARGV`` (local-agents 05).
57
+ # first-class"). Claude Code has none here: the person runs its own flow on the Runner,
58
+ # ``agentic-runner sign-in`` (``claude_sign_in``, QTS-1448), and no console starts it.
59
59
  DEVICE_LOGIN_ARGV: Final[dict[str, tuple[str, ...]]] = {
60
60
  "codex_cli": ("codex", "login", "--device-auth"),
61
61
  }
@@ -97,8 +97,6 @@ class DeviceLoginPrompt:
97
97
  verification_uri: str
98
98
  user_code: str
99
99
  expires_at: datetime
100
- # Claude Code's: what a relayed code names (local-agents 05).
101
- sign_in_id: str = ""
102
100
 
103
101
 
104
102
  class ContractDeviceLogin:
@@ -119,9 +117,7 @@ class ContractDeviceLogin:
119
117
  self._prompt_timeout_seconds = prompt_timeout_seconds
120
118
  self._output_limit_bytes = output_limit_bytes
121
119
 
122
- async def sign_in(
123
- self, contract_id: str, *, runtime_kind: str, method: str | None = None
124
- ) -> DeviceLoginPrompt:
120
+ async def sign_in(self, contract_id: str, *, runtime_kind: str) -> DeviceLoginPrompt:
125
121
  """Spawn the harness CLI as this Contract's own uid; return once it has printed
126
122
  its verification prompt, and leave it running to finish the funder's approval.
127
123
 
@@ -130,16 +126,6 @@ class ContractDeviceLogin:
130
126
  harness config root no other Contract's uid can read.
131
127
  """
132
128
 
133
- if runtime_kind == "claude_code":
134
- claude = await self.claude.start(contract_id, method=method)
135
- return DeviceLoginPrompt(
136
- contract_id=contract_id,
137
- runtime_kind=runtime_kind,
138
- verification_uri=claude.verification_uri,
139
- user_code="",
140
- expires_at=claude.expires_at,
141
- sign_in_id=claude.sign_in_id,
142
- )
143
129
  argv = self._argv_by_runtime.get(runtime_kind)
144
130
  if argv is None:
145
131
  raise UnknownHarnessError(f"no device-login command for runtime {runtime_kind!r}")
File without changes