agentic-runner 3.5.0__tar.gz → 3.6.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 (70) hide show
  1. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/device_login_activities.py +30 -12
  4. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/sealed_box.py +15 -4
  5. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/service.py +45 -2
  6. agentic_runner-3.6.0/src/agentic_runner/workers/claude_sign_in.py +534 -0
  7. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_device_login.py +32 -14
  8. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/.gitignore +0 -0
  9. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/LICENSE +0 -0
  10. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/README.md +0 -0
  11. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/pyproject.toml +0 -0
  12. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/activities.py +0 -0
  13. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/attempts.py +0 -0
  14. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/auth_mode.py +0 -0
  15. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/build.py +0 -0
  16. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/callback.py +0 -0
  17. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/child_watcher.py +0 -0
  18. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/cli.py +0 -0
  19. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/cli_floor.py +0 -0
  20. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/config.py +0 -0
  21. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/credentials.py +0 -0
  22. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/egress.py +0 -0
  23. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/harness_self_test.py +0 -0
  24. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/heartbeat_link.py +0 -0
  25. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/hooks.py +0 -0
  26. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/host_store.py +0 -0
  27. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/__init__.py +0 -0
  28. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  29. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  30. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  31. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  32. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  33. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  34. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  35. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  36. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  37. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/lifecycle.py +0 -0
  38. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/llm_proxy.py +0 -0
  39. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/mcp.py +0 -0
  40. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/message_store.py +0 -0
  41. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/private_state.py +0 -0
  42. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/py.typed +0 -0
  43. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  44. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/registration.py +0 -0
  45. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/runtime/__init__.py +0 -0
  46. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  47. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/__init__.py +0 -0
  48. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/__main__.py +0 -0
  49. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/control_plane.py +0 -0
  50. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/plugin.py +0 -0
  51. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  52. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/workflow.py +0 -0
  53. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/tiny_http.py +0 -0
  54. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/triage_activities.py +0 -0
  55. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/user_sources.py +0 -0
  56. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/__init__.py +0 -0
  57. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  58. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  59. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  60. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  61. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  62. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/command_policy.py +0 -0
  63. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  64. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  65. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  66. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  67. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  68. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/settings.py +0 -0
  69. {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/skills.py +0 -0
  70. {agentic_runner-3.5.0 → agentic_runner-3.6.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.5.0
3
+ Version: 3.6.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.5.0"
10
+ __version__ = "3.6.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -13,7 +13,8 @@ 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
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
17
18
  from agentic_runner.workers.contract_device_login import ContractDeviceLogin
18
19
  from agentic_runner.workers.contract_isolation import ContractIsolation
19
20
  from agentic_runner_contracts.activity_io import (
@@ -36,10 +37,10 @@ class ContractDeviceLoginActivities:
36
37
  host_party: str | None = None,
37
38
  ) -> None:
38
39
  self._contract_isolation = contract_isolation
39
- # The party this process was registered as hosted by. A shared Runner never runs a
40
- # subscription, so it never starts or reads one either (local-agents 04).
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).
41
42
  self._host_party = host_party
42
- # Injectable for tests; None falls through to the real `codex` subprocess.
43
+ # Injectable for tests; None falls through to the real vendor CLIs.
43
44
  self._device_login = device_login or ContractDeviceLogin(contract_isolation)
44
45
 
45
46
  def activity_callables(self) -> list[Callable[..., object]]:
@@ -52,9 +53,12 @@ class ContractDeviceLoginActivities:
52
53
  async def sign_in_contract_device_login(
53
54
  self, request: ContractDeviceLoginInput
54
55
  ) -> ContractDeviceLoginResult:
55
- self._refuse_on_shared_runner()
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
60
  prompt = await self._device_login.sign_in(
57
- request.contract_id, runtime_kind=request.runtime_kind
61
+ request.contract_id, runtime_kind=request.runtime_kind, method=request.method
58
62
  )
59
63
  return ContractDeviceLoginResult(
60
64
  contract_id=prompt.contract_id,
@@ -62,12 +66,24 @@ class ContractDeviceLoginActivities:
62
66
  verification_uri=prompt.verification_uri,
63
67
  user_code=prompt.user_code,
64
68
  expires_at=prompt.expires_at.isoformat(),
69
+ sign_in_id=prompt.sign_in_id,
65
70
  )
66
71
 
67
72
  @activity.defn(name="check_contract_device_login_status")
68
73
  async def check_contract_device_login_status(
69
74
  self, request: ContractDeviceLoginStatusInput
70
75
  ) -> ContractDeviceLoginStatusResult:
76
+ if request.runtime_kind == "claude_code":
77
+ self._refuse_on_shared_runner(allow=True)
78
+ status = await self._device_login.claude.status(request.contract_id)
79
+ return ContractDeviceLoginStatusResult(
80
+ contract_id=request.contract_id,
81
+ runtime_kind=request.runtime_kind,
82
+ token_present=status.logged_in,
83
+ delivered_at=None,
84
+ auth_method=status.auth_method,
85
+ subscription_type=status.subscription_type,
86
+ )
71
87
  self._refuse_on_shared_runner()
72
88
  present = self._device_login.token_present(
73
89
  request.contract_id, runtime_kind=request.runtime_kind
@@ -86,18 +102,20 @@ class ContractDeviceLoginActivities:
86
102
  delivered_at=delivered_at,
87
103
  )
88
104
 
89
- def _refuse_on_shared_runner(self) -> None:
105
+ def _refuse_on_shared_runner(self, *, allow: bool = False) -> None:
90
106
  """Non-retryable, and typed by the rule: there is no Work Record to write Evidence
91
107
  on here, so the failure the platform's sign-in workflow receives names it.
92
108
 
93
- Only a Runner known to be the person's own signs in: one whose state names no host
94
- party (registered before issue 42) is refused too, as `choose_auth_mode` never runs
95
- a subscription on it either."""
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."""
96
113
 
97
- if self._host_party != "user":
114
+ if self._host_party != "user" and not (allow and is_shared_runner(self._host_party)):
98
115
  raise ApplicationError(
99
116
  f"this Runner is hosted by {self._host_party!r}; only a person's own Runner "
100
- "signs in, a shared Runner runs API keys only",
117
+ "signs in, a shared Runner runs API keys and Claude Code long-lived tokens "
118
+ "only",
101
119
  type=SHARED_RUNNER_SIGN_IN_RULE,
102
120
  non_retryable=True,
103
121
  )
@@ -61,6 +61,7 @@ from agentic_runner_contracts.sealed_credential import (
61
61
  SEAL_DOMAIN,
62
62
  OpenedCredential,
63
63
  SealedCredential,
64
+ SealedSignInCode,
64
65
  delivery_binding,
65
66
  key_fingerprint,
66
67
  )
@@ -516,15 +517,25 @@ class SealedCredentialStream:
516
517
  if not self._awaiting:
517
518
  self._keys.retire_previous()
518
519
 
520
+ def open_sign_in_code(self, sealed: SealedSignInCode) -> str | None:
521
+ """A relayed sign-in code, or None when it does not open (local-agents 05).
522
+
523
+ Opened with the same Recipient Key as a slot, but never held: the caller types it
524
+ into the waiting CLI and drops it.
525
+ """
526
+
527
+ return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
528
+
519
529
  def _open(self, sealed: SealedCredential) -> str | None:
530
+ return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
531
+
532
+ def _open_with(self, recipient_key_id: str, binding: bytes, ciphertext: str) -> str | None:
520
533
  for pair in (self._keys.current(), self._keys.previous()):
521
- if pair is None or pair.key_id != sealed.recipient_key_id:
534
+ if pair is None or pair.key_id != recipient_key_id:
522
535
  continue
523
536
  try:
524
537
  return open_sealed(
525
- private_key=pair.private_key,
526
- binding=sealed.binding(),
527
- ciphertext=sealed.ciphertext,
538
+ private_key=pair.private_key, binding=binding, ciphertext=ciphertext
528
539
  )
529
540
  except SealedCredentialError:
530
541
  return None
@@ -102,7 +102,9 @@ from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
102
102
  from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
103
103
  from agentic_runner.workers.agent_runtime import AgentRuntime
104
104
  from agentic_runner.workers.claude_runtime import ClaudeRuntime
105
+ from agentic_runner.workers.claude_sign_in import ClaudeSignIns
105
106
  from agentic_runner.workers.codex_runtime import CodexRuntime
107
+ from agentic_runner.workers.contract_device_login import ContractDeviceLogin
106
108
  from agentic_runner.workers.contract_isolation import UID_MAP_FILENAME, ContractIsolation
107
109
  from agentic_runner.workers.fastapi_client import DirectiveTokenSource, RunnerFastApiClient
108
110
  from agentic_runner.workers.settings import WorkerSettings, get_worker_settings
@@ -124,7 +126,11 @@ from agentic_runner_contracts.runner_registration import (
124
126
  ResourcePressure,
125
127
  )
126
128
  from agentic_runner_contracts.runtime_context import WorkerRuntimeContextResolver
127
- from agentic_runner_contracts.sealed_credential import key_fingerprint
129
+ from agentic_runner_contracts.sealed_credential import (
130
+ SignInCodeOutcome,
131
+ SignInCodeRelay,
132
+ key_fingerprint,
133
+ )
128
134
 
129
135
  __all__ = [
130
136
  "AGENT_TOKEN_ENV",
@@ -577,6 +583,11 @@ class ControlPlaneStream:
577
583
  self_tests: HarnessSelfTests | None = None
578
584
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
579
585
  llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
586
+ # Local-agents 05: the Claude Code sign-ins a relayed code is typed into.
587
+ sign_ins: ClaudeSignIns | None = None
588
+ # Reported once, on the beat after the relay: a lost beat loses the outcome, and the
589
+ # console's own wait on the sign-in is what tells the person.
590
+ _sign_in_outcomes: list[SignInCodeRelay] = field(default_factory=list)
580
591
  _delivered: set[tuple[str, str]] = field(default_factory=set)
581
592
  _offered_to_proxy: dict[tuple[str, str], str] = field(default_factory=dict)
582
593
  _last_seen: tuple[float, datetime] | None = None
@@ -679,11 +690,14 @@ class ControlPlaneStream:
679
690
  clock_skew_seconds=self._clock_skew(now),
680
691
  source_status=self.sources.statuses() if self.sources is not None else [],
681
692
  tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
693
+ sign_in_codes=self._sign_in_outcomes,
682
694
  )
695
+ self._sign_in_outcomes = []
683
696
  ack = await self.client.heartbeat(self.state, envelope)
684
697
  if self.sources is not None:
685
698
  self.sources.assign(ack.user_sources)
686
699
  await self._deliver_transcripts(ack)
700
+ await self._relay_sign_in_codes(ack)
687
701
  if self.lifecycle is not None:
688
702
  self.lifecycle.acknowledge(lifecycle)
689
703
  if self.heartbeat_stamp is not None:
@@ -730,6 +744,28 @@ class ControlPlaneStream:
730
744
  "transcript %s could not be delivered: %s", request.request_id, error
731
745
  )
732
746
 
747
+ async def _relay_sign_in_codes(self, ack: HeartbeatAck) -> None:
748
+ """Type each relayed code into the sign-in it names (local-agents 05).
749
+
750
+ The code is opened, written to that sign-in's PTY and dropped here; nothing logs
751
+ it and only its outcome travels back.
752
+ """
753
+
754
+ for sealed in ack.sign_in_codes:
755
+ code = self.sealed.open_sign_in_code(sealed)
756
+ if code is None:
757
+ outcome = SignInCodeOutcome.UNOPENABLE
758
+ elif self.sign_ins is None:
759
+ outcome = SignInCodeOutcome.UNKNOWN
760
+ else:
761
+ outcome = await self.sign_ins.relay(
762
+ str(sealed.contract_id), sealed.sign_in_id, code
763
+ )
764
+ del code
765
+ self._sign_in_outcomes.append(
766
+ SignInCodeRelay(sign_in_id=sealed.sign_in_id, outcome=outcome)
767
+ )
768
+
733
769
  def _sync_delivered(self) -> None:
734
770
  """The resolver holds exactly what the last ack let this installation open."""
735
771
 
@@ -1019,6 +1055,8 @@ async def _serve_registered(
1019
1055
  )
1020
1056
  messages = MessageStore(state_dir / MESSAGES_DIR)
1021
1057
  stream.messages = messages
1058
+ sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can_change_uid))
1059
+ stream.sign_ins = sign_ins
1022
1060
  tool_servers = ToolServerHealthLog()
1023
1061
  stream.tool_servers = tool_servers
1024
1062
  link = HeartbeatLink(stream, ceilings=proxy.ceilings)
@@ -1065,6 +1103,7 @@ async def _serve_registered(
1065
1103
  token_source=stream,
1066
1104
  messages=messages,
1067
1105
  tool_servers=tool_servers,
1106
+ sign_ins=sign_ins,
1068
1107
  # The wake signal rides the connection this process polls with: one
1069
1108
  # namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
1070
1109
  signaller=TemporalWorkflowSignaller(client),
@@ -1092,6 +1131,7 @@ async def _serve_registered(
1092
1131
  task.cancel()
1093
1132
  with contextlib.suppress(asyncio.CancelledError):
1094
1133
  await task
1134
+ await sign_ins.close()
1095
1135
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
1096
1136
  await _report_stop(link, lifecycle)
1097
1137
  return 1 if revoked.is_set() else 0
@@ -1204,6 +1244,7 @@ def _activities(
1204
1244
  messages: MessageStore | None = None,
1205
1245
  signaller: TemporalWorkflowSignaller | None = None,
1206
1246
  tool_servers: ToolServerHealthLog | None = None,
1247
+ sign_ins: ClaudeSignIns | None = None,
1207
1248
  ) -> list[Callable[..., Any]]:
1208
1249
  github_client = GitHubAppClient(
1209
1250
  app_id=os.getenv("GITHUB_APP_ID"),
@@ -1255,7 +1296,9 @@ def _activities(
1255
1296
  return [
1256
1297
  *ralph.activity_callables(),
1257
1298
  *ContractDeviceLoginActivities(
1258
- contract_isolation=isolation, host_party=state.host_party or None
1299
+ contract_isolation=isolation,
1300
+ device_login=ContractDeviceLogin(isolation, claude=sign_ins),
1301
+ host_party=state.host_party or None,
1259
1302
  ).activity_callables(),
1260
1303
  *RunnerTriageActivities(
1261
1304
  proxy=proxy,
@@ -0,0 +1,534 @@
1
+ """Claude Code's in-place sign-in for one Contract (local-agents 05, PRD decision 13).
2
+
3
+ Two methods, both made in the Contract's own harness root and neither ever delivered:
4
+
5
+ * ``oauth_token`` (the default): ``claude setup-token`` mints a long-lived (1-year)
6
+ subscription token. The CLI prints it and exits; this module writes it to
7
+ ``{harness_root}/oauth-token`` (0600, the Contract's uid) and drops it. It is the
8
+ only moment the Runner process holds it (PRD decision 13 amends decision 1 for this).
9
+ * ``claude_ai``: ``claude auth login --claudeai``, a refreshing login the CLI keeps itself
10
+ -- ``.credentials.json`` on Linux, a Keychain item on macOS.
11
+
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.
20
+
21
+ macOS (the 2026-10-08 spike): Claude saves a ``claude_ai`` login with the ``security`` CLI
22
+ into the default keychain of the ``HOME`` it is given, and a Contract's sandbox ``HOME``
23
+ has none -- the save fails. So that method gets a per-Contract keychain in the harness
24
+ root, made the sandbox ``HOME``'s default, and the launcher unlocks it before every spawn:
25
+ a locked keychain makes ``security`` block on a GUI prompt that nobody will answer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import contextlib
32
+ import fcntl
33
+ import json
34
+ import logging
35
+ import os
36
+ import pty
37
+ import re
38
+ import secrets
39
+ import signal
40
+ import struct
41
+ import sys
42
+ import termios
43
+ from collections.abc import Callable, Sequence
44
+ from dataclasses import dataclass, field
45
+ from datetime import UTC, datetime, timedelta
46
+ from pathlib import Path
47
+ from typing import Final
48
+
49
+ from agentic_runner.workers._runtime_support import run_subprocess_exec
50
+ from agentic_runner.workers.contract_isolation import ContractIsolation, DirectiveSandbox
51
+ from agentic_runner_contracts.sealed_credential import SignInCodeOutcome
52
+
53
+ __all__ = [
54
+ "CLAUDE_AI",
55
+ "CLAUDE_SIGN_IN_ARGV",
56
+ "KEYCHAIN_FILE",
57
+ "KEYCHAIN_PASSWORD_FILE",
58
+ "OAUTH_TOKEN",
59
+ "OAUTH_TOKEN_FILE",
60
+ "ClaudeAuthStatus",
61
+ "ClaudeSignInError",
62
+ "ClaudeSignInPrompt",
63
+ "ClaudeSignIns",
64
+ "UnknownSignInMethodError",
65
+ "launcher_argv",
66
+ ]
67
+
68
+ RUNTIME_KIND: Final[str] = "claude_code"
69
+ OAUTH_TOKEN: Final[str] = "oauth_token"
70
+ CLAUDE_AI: Final[str] = "claude_ai"
71
+
72
+ CLAUDE_SIGN_IN_ARGV: Final[dict[str, tuple[str, ...]]] = {
73
+ OAUTH_TOKEN: ("claude", "setup-token"),
74
+ CLAUDE_AI: ("claude", "auth", "login", "--claudeai"),
75
+ }
76
+ _STATUS_ARGV: Final[tuple[str, ...]] = ("claude", "auth", "status", "--json")
77
+
78
+ OAUTH_TOKEN_FILE: Final[str] = "oauth-token"
79
+ KEYCHAIN_FILE: Final[str] = "claude.keychain-db"
80
+ KEYCHAIN_PASSWORD_FILE: Final[str] = "keychain-password"
81
+
82
+ # Runs as the Contract's uid, so the token and the keychain password are read by the
83
+ # Contract's own process and never by the Runner's. The scrub is claude's own switch: it
84
+ # strips CLAUDE_CODE_OAUTH_TOKEN (and every other Anthropic credential) from the env of
85
+ # every command the Agent runs, so `env` in a Directive does not print the token.
86
+ _LAUNCHER: Final[str] = """\
87
+ root=$1
88
+ shift
89
+ if [ -f "$root/oauth-token" ]; then
90
+ CLAUDE_CODE_OAUTH_TOKEN=$(cat -- "$root/oauth-token") || exit 126
91
+ export CLAUDE_CODE_OAUTH_TOKEN
92
+ fi
93
+ if [ -f "$root/keychain-password" ]; then
94
+ security unlock-keychain "$root/claude.keychain-db" <"$root/keychain-password" \\
95
+ >/dev/null 2>&1 || exit 126
96
+ fi
97
+ CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1
98
+ export CLAUDE_CODE_SUBPROCESS_ENV_SCRUB
99
+ exec "$@"
100
+ """
101
+
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.
110
+ _PTY_COLUMNS: Final[int] = 1000
111
+ _PTY_ROWS: Final[int] = 50
112
+ _DEFAULT_PROMPT_TIMEOUT_SECONDS: Final[float] = 30.0
113
+ _STATUS_TIMEOUT_SECONDS: Final[int] = 30
114
+ _OUTPUT_LIMIT_BYTES: Final[int] = 65_536
115
+
116
+ _logger = logging.getLogger(__name__)
117
+
118
+ # 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
120
+ # CAP_CHOWN and CAP_DAC_OVERRIDE, would follow it into another Contract's root; as the
121
+ # Contract's uid the write or unlink reaches only what that Contract already could.
122
+ # ``value`` None removes the file.
123
+ _PRIVATE_FILE_SCRIPT: Final[str] = """
124
+ import json, os, secrets, sys
125
+ path, value = json.load(sys.stdin)
126
+ if value is None:
127
+ try:
128
+ os.unlink(path)
129
+ except FileNotFoundError:
130
+ pass
131
+ sys.exit()
132
+ directory, name = os.path.split(path)
133
+ temporary = os.path.join(directory, "." + name + "." + secrets.token_hex(8) + ".tmp")
134
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW
135
+ try:
136
+ with os.fdopen(os.open(temporary, flags, 0o600), "w", encoding="utf-8") as handle:
137
+ handle.write(value)
138
+ handle.flush()
139
+ os.fsync(handle.fileno())
140
+ os.replace(temporary, path)
141
+ except BaseException:
142
+ try:
143
+ os.unlink(temporary)
144
+ except FileNotFoundError:
145
+ pass
146
+ raise
147
+ """
148
+
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
+ _ANSI: Final[re.Pattern[str]] = re.compile(r"\x1b(?:\][^\x07]*\x07|\[[0-9;?>]*[A-Za-z]|[78])")
154
+ _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
+
158
+
159
+ class ClaudeSignInError(RuntimeError):
160
+ """The CLI never printed a usable sign-in prompt, or its status could not be read."""
161
+
162
+
163
+ class UnknownSignInMethodError(ValueError):
164
+ """A ``method`` this Runner has no Claude Code sign-in command for."""
165
+
166
+
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
+ @dataclass(frozen=True, slots=True)
175
+ class ClaudeAuthStatus:
176
+ """``claude auth status --json``'s three fields, and nothing else it reports."""
177
+
178
+ logged_in: bool
179
+ auth_method: str | None
180
+ subscription_type: str | None
181
+
182
+
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
+ def launcher_argv(harness_root: Path, argv: Sequence[str]) -> list[str]:
200
+ """``argv`` behind the launcher that hands it this harness root's login."""
201
+
202
+ return ["/bin/sh", "-c", _LAUNCHER, "agentic-claude", str(harness_root), *argv]
203
+
204
+
205
+ class ClaudeSignIns:
206
+ """Every Claude Code sign-in this Runner has started and is still waiting on."""
207
+
208
+ def __init__(
209
+ self,
210
+ isolation: ContractIsolation,
211
+ *,
212
+ argv_by_method: dict[str, tuple[str, ...]] | None = None,
213
+ status_argv: Sequence[str] = _STATUS_ARGV,
214
+ prompt_timeout_seconds: float = _DEFAULT_PROMPT_TIMEOUT_SECONDS,
215
+ window: timedelta = SIGN_IN_WINDOW,
216
+ platform: str = sys.platform,
217
+ clock: Callable[[], float] | None = None,
218
+ ) -> None:
219
+ self._isolation = isolation
220
+ self._argv_by_method = argv_by_method or CLAUDE_SIGN_IN_ARGV
221
+ self._status_argv = list(status_argv)
222
+ self._prompt_timeout_seconds = prompt_timeout_seconds
223
+ self._window = window
224
+ 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
+
232
+ @property
233
+ def waiting(self) -> int:
234
+ return len(self._waiting)
235
+
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
+ argv = self._argv_by_method.get(method)
241
+ if argv is None:
242
+ raise UnknownSignInMethodError(f"no Claude Code sign-in method {method!r}")
243
+ 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
+ if method == CLAUDE_AI and self._platform == "darwin":
249
+ await self._ensure_keychain(sandbox)
250
+ session = await self._spawn(contract_id, method, argv, sandbox)
251
+ 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
288
+
289
+ async def status(self, contract_id: str) -> ClaudeAuthStatus:
290
+ """Ask the CLI, as the Contract's uid, whether it is signed in.
291
+
292
+ The Runner opens nothing under the harness root: the launcher (the Contract's own
293
+ process) reads the token file, and the CLI its own login. On claude 2.1.292 a
294
+ present token reports ``loggedIn`` without contacting Anthropic, so this proves a
295
+ token is there, not that it is still valid (local-agents 08 finds that out).
296
+ """
297
+
298
+ sandbox = self._isolation.sandbox(contract_id, runtime_kind=RUNTIME_KIND)
299
+ result = await run_subprocess_exec(
300
+ argv=launcher_argv(sandbox.harness_config_dir, self._status_argv),
301
+ cwd=sandbox.home_dir,
302
+ env=_harness_env(sandbox),
303
+ stdin=None,
304
+ timeout_seconds=_STATUS_TIMEOUT_SECONDS,
305
+ output_limit_bytes=_OUTPUT_LIMIT_BYTES,
306
+ sandbox=sandbox,
307
+ )
308
+ # `auth status` exits 1 when signed out and still prints its JSON.
309
+ try:
310
+ report = json.loads(result.stdout)
311
+ except json.JSONDecodeError:
312
+ raise ClaudeSignInError(
313
+ f"claude auth status printed no JSON (exit {result.exit_code})"
314
+ ) from None
315
+ if not isinstance(report, dict):
316
+ raise ClaudeSignInError("claude auth status printed JSON that is not an object")
317
+ method = report.get("authMethod")
318
+ plan = report.get("subscriptionType")
319
+ return ClaudeAuthStatus(
320
+ logged_in=report.get("loggedIn") is True,
321
+ auth_method=method if isinstance(method, str) and method != "none" else None,
322
+ subscription_type=plan if isinstance(plan, str) else None,
323
+ )
324
+
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
+ async def _ensure_keychain(self, sandbox: DirectiveSandbox) -> None:
454
+ """A per-Contract keychain, the default of the sandbox ``HOME`` (macOS only)."""
455
+
456
+ root = sandbox.harness_config_dir
457
+ keychain = root / KEYCHAIN_FILE
458
+ password_file = root / KEYCHAIN_PASSWORD_FILE
459
+ if keychain.exists() and password_file.exists():
460
+ return
461
+ password = secrets.token_urlsafe(32)
462
+ await _write_private(password_file, password, sandbox.uid)
463
+ # The password goes in on stdin, twice (new and retyped): in argv `ps` shows it.
464
+ commands: list[tuple[list[str], str | None]] = [
465
+ (["security", "create-keychain", str(keychain)], f"{password}\n{password}\n"),
466
+ # No auto-lock: the launcher unlocks before each spawn, but a Directive can
467
+ # outlive any timeout, and a lock mid-turn blocks on a GUI prompt.
468
+ (["security", "set-keychain-settings", str(keychain)], None),
469
+ (["security", "list-keychains", "-d", "user", "-s", str(keychain)], None),
470
+ (["security", "default-keychain", "-d", "user", "-s", str(keychain)], None),
471
+ ]
472
+ for argv, stdin in commands:
473
+ result = await run_subprocess_exec(
474
+ argv=argv,
475
+ cwd=sandbox.home_dir,
476
+ env=_harness_env(sandbox),
477
+ stdin=stdin,
478
+ timeout_seconds=_STATUS_TIMEOUT_SECONDS,
479
+ output_limit_bytes=_OUTPUT_LIMIT_BYTES,
480
+ sandbox=sandbox,
481
+ )
482
+ if result.exit_code != 0:
483
+ raise ClaudeSignInError(f"security {argv[1]} failed (exit {result.exit_code})")
484
+
485
+
486
+ def _harness_env(sandbox: DirectiveSandbox) -> dict[str, str]:
487
+ return {
488
+ "HOME": str(sandbox.home_dir),
489
+ "TMPDIR": str(sandbox.tmp_dir),
490
+ "PATH": os.environ.get("PATH") or os.defpath,
491
+ "CLAUDE_CONFIG_DIR": str(sandbox.harness_config_dir),
492
+ }
493
+
494
+
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
+ def _printed_token(text: str) -> str | None:
503
+ match = _TOKEN.search(_ANSI.sub("", text))
504
+ return match.group(0) if match else None
505
+
506
+
507
+ def _keep_tail(output: bytearray, chunk: bytes) -> None:
508
+ """The last ``_OUTPUT_LIMIT_BYTES``: the token is the last thing ``setup-token`` prints,
509
+ so a cap that kept the head would drop it from a long output."""
510
+
511
+ output.extend(chunk)
512
+ del output[:-_OUTPUT_LIMIT_BYTES]
513
+
514
+
515
+ async def _write_private(path: Path, value: str | None, uid: int | None) -> None:
516
+ """0600 and the Contract's from the first byte, replacing whatever was there; or, for
517
+ ``value`` None, gone. Done as the Contract's uid (see ``_PRIVATE_FILE_SCRIPT``)."""
518
+
519
+ identity: dict[str, object] = (
520
+ {} if uid is None else {"user": uid, "group": uid, "extra_groups": []}
521
+ )
522
+ process = await asyncio.create_subprocess_exec(
523
+ sys.executable,
524
+ "-I",
525
+ "-c",
526
+ _PRIVATE_FILE_SCRIPT,
527
+ stdin=asyncio.subprocess.PIPE,
528
+ stdout=asyncio.subprocess.DEVNULL,
529
+ stderr=asyncio.subprocess.DEVNULL,
530
+ **identity, # type: ignore[arg-type]
531
+ )
532
+ await process.communicate(json.dumps([str(path), value]).encode("utf-8"))
533
+ if process.returncode != 0:
534
+ raise ClaudeSignInError(f"writing {path.name} failed (exit {process.returncode})")
@@ -32,6 +32,7 @@ from pathlib import Path
32
32
  from typing import Final
33
33
 
34
34
  from agentic_runner.workers._runtime_support import run_subprocess_launch_and_detach
35
+ from agentic_runner.workers.claude_sign_in import OAUTH_TOKEN_FILE, ClaudeSignIns
35
36
  from agentic_runner.workers.contract_isolation import ContractIsolation
36
37
 
37
38
  __all__ = [
@@ -43,18 +44,18 @@ __all__ = [
43
44
  "login_file_counts",
44
45
  ]
45
46
 
46
- # The token file each harness's own device-login flow writes, relative to its config root
47
+ # The login files each harness's own sign-in writes, relative to its config root
47
48
  # (research/29 §1.1, §2.1). Read only with `Path.stat`/`Path.is_file` below, never opened.
48
- _TOKEN_FILE_BY_RUNTIME: Final[dict[str, str]] = {
49
- "codex_cli": "auth.json",
50
- "claude_code": ".credentials.json",
49
+ # Claude's `claude_ai` login on macOS is a Keychain item, not a file: its presence is
50
+ # `ClaudeSignIns.status`, and this table serves only the residue count.
51
+ _TOKEN_FILES_BY_RUNTIME: Final[dict[str, tuple[str, ...]]] = {
52
+ "codex_cli": ("auth.json",),
53
+ "claude_code": (".credentials.json", OAUTH_TOKEN_FILE),
51
54
  }
52
55
 
53
56
  # Codex's non-interactive device-code flow (research/29 §1.5, "Headless login is
54
- # first-class"). Claude Code's own login is an interactive `/login` paste-code flow with
55
- # no scriptable device-code equivalent (research/29 §2.1); its M1 path is the
56
- # `claude setup-token` bearer instead, which is a *value* and so goes through
57
- # ``services.llm_credential.replace_api_key`` like any other API key, never through here.
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).
58
59
  DEVICE_LOGIN_ARGV: Final[dict[str, tuple[str, ...]]] = {
59
60
  "codex_cli": ("codex", "login", "--device-auth"),
60
61
  }
@@ -96,6 +97,8 @@ class DeviceLoginPrompt:
96
97
  verification_uri: str
97
98
  user_code: str
98
99
  expires_at: datetime
100
+ # Claude Code's: what a relayed code names (local-agents 05).
101
+ sign_in_id: str = ""
99
102
 
100
103
 
101
104
  class ContractDeviceLogin:
@@ -108,13 +111,17 @@ class ContractDeviceLogin:
108
111
  argv_by_runtime: dict[str, tuple[str, ...]] | None = None,
109
112
  prompt_timeout_seconds: float = _DEFAULT_PROMPT_TIMEOUT_SECONDS,
110
113
  output_limit_bytes: int = _DEFAULT_OUTPUT_LIMIT_BYTES,
114
+ claude: ClaudeSignIns | None = None,
111
115
  ) -> None:
112
116
  self._isolation = isolation
117
+ self.claude = claude or ClaudeSignIns(isolation)
113
118
  self._argv_by_runtime = argv_by_runtime or DEVICE_LOGIN_ARGV
114
119
  self._prompt_timeout_seconds = prompt_timeout_seconds
115
120
  self._output_limit_bytes = output_limit_bytes
116
121
 
117
- async def sign_in(self, contract_id: str, *, runtime_kind: str) -> DeviceLoginPrompt:
122
+ async def sign_in(
123
+ self, contract_id: str, *, runtime_kind: str, method: str | None = None
124
+ ) -> DeviceLoginPrompt:
118
125
  """Spawn the harness CLI as this Contract's own uid; return once it has printed
119
126
  its verification prompt, and leave it running to finish the funder's approval.
120
127
 
@@ -123,6 +130,16 @@ class ContractDeviceLogin:
123
130
  harness config root no other Contract's uid can read.
124
131
  """
125
132
 
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
+ )
126
143
  argv = self._argv_by_runtime.get(runtime_kind)
127
144
  if argv is None:
128
145
  raise UnknownHarnessError(f"no device-login command for runtime {runtime_kind!r}")
@@ -170,10 +187,10 @@ class ContractDeviceLogin:
170
187
  return None
171
188
 
172
189
  def _token_path(self, contract_id: str, *, runtime_kind: str) -> Path | None:
173
- filename = _TOKEN_FILE_BY_RUNTIME.get(runtime_kind)
174
- if filename is None:
190
+ filenames = _TOKEN_FILES_BY_RUNTIME.get(runtime_kind)
191
+ if filenames is None:
175
192
  return None
176
- return self._isolation.harness_config_dir(contract_id, runtime_kind) / filename
193
+ return self._isolation.harness_config_dir(contract_id, runtime_kind) / filenames[0]
177
194
 
178
195
 
179
196
  def login_file_counts(isolation: ContractIsolation, contract_id: str | None) -> dict[str, int]:
@@ -184,8 +201,9 @@ def login_file_counts(isolation: ContractIsolation, contract_id: str | None) ->
184
201
  """
185
202
 
186
203
  counts: dict[str, int] = {}
187
- for runtime_kind, filename in _TOKEN_FILE_BY_RUNTIME.items():
188
- if (isolation.harness_config_dir(contract_id, runtime_kind) / filename).is_file():
204
+ for runtime_kind, filenames in _TOKEN_FILES_BY_RUNTIME.items():
205
+ root = isolation.harness_config_dir(contract_id, runtime_kind)
206
+ if any((root / filename).is_file() for filename in filenames):
189
207
  counts[runtime_kind] = 1
190
208
  return counts
191
209
 
File without changes
File without changes