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.
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/PKG-INFO +1 -1
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/device_login_activities.py +30 -12
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/sealed_box.py +15 -4
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/service.py +45 -2
- agentic_runner-3.6.0/src/agentic_runner/workers/claude_sign_in.py +534 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_device_login.py +32 -14
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/.gitignore +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/LICENSE +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/README.md +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/pyproject.toml +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/activities.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -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
|
|
40
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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 !=
|
|
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
|
|
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,
|
|
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})")
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
#
|
|
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(
|
|
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
|
-
|
|
174
|
-
if
|
|
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) /
|
|
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,
|
|
188
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/gh_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/runtime/verifier_command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/testing/test_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.5.0 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|