agentic-runner 3.4.2__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.4.2 → agentic_runner-3.6.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/activities.py +92 -30
  4. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/device_login_activities.py +30 -12
  5. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/sealed_box.py +15 -4
  6. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/service.py +55 -3
  7. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/testing/control_plane.py +2 -2
  8. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/_runtime_support.py +23 -6
  9. agentic_runner-3.6.0/src/agentic_runner/workers/acp_runtime.py +938 -0
  10. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/agent_runtime.py +32 -0
  11. agentic_runner-3.6.0/src/agentic_runner/workers/claude_sign_in.py +534 -0
  12. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_device_login.py +32 -14
  13. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/settings.py +7 -0
  14. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/.gitignore +0 -0
  15. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/LICENSE +0 -0
  16. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/README.md +0 -0
  17. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/pyproject.toml +0 -0
  18. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/attempts.py +0 -0
  19. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/auth_mode.py +0 -0
  20. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/build.py +0 -0
  21. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/callback.py +0 -0
  22. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/child_watcher.py +0 -0
  23. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/cli.py +0 -0
  24. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/cli_floor.py +0 -0
  25. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/config.py +0 -0
  26. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/credentials.py +0 -0
  27. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/egress.py +0 -0
  28. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/harness_self_test.py +0 -0
  29. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/heartbeat_link.py +0 -0
  30. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/hooks.py +0 -0
  31. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/host_store.py +0 -0
  32. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/__init__.py +0 -0
  33. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  34. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  35. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  36. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  37. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  38. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  39. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  40. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  41. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  42. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/lifecycle.py +0 -0
  43. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/llm_proxy.py +0 -0
  44. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/mcp.py +0 -0
  45. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/message_store.py +0 -0
  46. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/private_state.py +0 -0
  47. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/py.typed +0 -0
  48. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  49. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/registration.py +0 -0
  50. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/runtime/__init__.py +0 -0
  51. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  52. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/testing/__init__.py +0 -0
  53. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/testing/__main__.py +0 -0
  54. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/testing/plugin.py +0 -0
  55. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  56. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/testing/workflow.py +0 -0
  57. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/tiny_http.py +0 -0
  58. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/triage_activities.py +0 -0
  59. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/user_sources.py +0 -0
  60. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/__init__.py +0 -0
  61. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  62. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  63. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/command_policy.py +0 -0
  64. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  65. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  66. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  67. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  68. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  69. {agentic_runner-3.4.2 → agentic_runner-3.6.0}/src/agentic_runner/workers/skills.py +0 -0
  70. {agentic_runner-3.4.2 → 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.4.2
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.4.2"
10
+ __version__ = "3.6.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -29,7 +29,7 @@ import shlex
29
29
  import shutil
30
30
  import tempfile
31
31
  import time
32
- from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
32
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
33
33
  from dataclasses import asdict, dataclass, field, fields, replace
34
34
  from datetime import UTC, datetime
35
35
  from pathlib import Path
@@ -128,6 +128,8 @@ from agentic_runner.workers.agent_runtime import (
128
128
  AuthMode,
129
129
  DirectiveRequest,
130
130
  DirectiveResult,
131
+ ModelUsage,
132
+ PermissionFallback,
131
133
  ResumableAgentRuntime,
132
134
  )
133
135
  from agentic_runner.workers.contract_device_login import login_file_counts
@@ -588,6 +590,9 @@ class _RuntimeContextState:
588
590
  # on the Profile's `command_policy` blob — the sandbox floor already lives there, and
589
591
  # a ceiling is exactly a floor rule. None means the Runner's own default applies.
590
592
  memory_limit_bytes: int | None = None
593
+ # PRD decision 12: what a runtime asked per command does when the command policy has
594
+ # no answer. Carried on the same `command_policy` blob as the ceiling above.
595
+ permission_fallback: PermissionFallback = PermissionFallback.DENY
591
596
  # Pushed on change and applied on receipt (ADR-0011 §11, PRD issue 44): the evaluator
592
597
  # is local, so a narrowing lands on the next verb this state is read for.
593
598
  grant_snapshot: GrantSnapshot = UNENFORCED_SNAPSHOT
@@ -1296,6 +1301,56 @@ def _profile_memory_limit_bytes(command_policy: Mapping[str, object]) -> int | N
1296
1301
  return None
1297
1302
 
1298
1303
 
1304
+ def _model_usage_reports(
1305
+ cli_kind: str, model_usage: Sequence[ModelUsage]
1306
+ ) -> list[tuple[dict[str, Any], str | None]]:
1307
+ """A harness's per-model usage in the shapes the harness usage route already prices.
1308
+
1309
+ Claude Code: one ``result`` message whose ``modelUsage`` names every model. Codex: one
1310
+ ``turn.completed.usage`` per model, whose ``input_tokens`` includes the cached reads,
1311
+ with the model the harness itself named rather than the Agent's configured one.
1312
+ """
1313
+
1314
+ if cli_kind == "claude_code":
1315
+ return [
1316
+ (
1317
+ {
1318
+ "type": "result",
1319
+ "modelUsage": {
1320
+ row.model: {
1321
+ "inputTokens": row.input_tokens,
1322
+ "outputTokens": row.output_tokens,
1323
+ "cacheReadInputTokens": row.cached_read_tokens,
1324
+ "cacheCreationInputTokens": row.cached_write_tokens,
1325
+ }
1326
+ for row in model_usage
1327
+ },
1328
+ },
1329
+ None,
1330
+ )
1331
+ ]
1332
+ return [
1333
+ (
1334
+ {
1335
+ "input_tokens": row.input_tokens + row.cached_read_tokens,
1336
+ "cached_input_tokens": row.cached_read_tokens,
1337
+ "output_tokens": row.output_tokens,
1338
+ "reasoning_output_tokens": row.reasoning_output_tokens,
1339
+ },
1340
+ row.model,
1341
+ )
1342
+ for row in model_usage
1343
+ ]
1344
+
1345
+
1346
+ def _profile_permission_fallback(command_policy: Mapping[str, object]) -> PermissionFallback:
1347
+ """The Profile's `permission_fallback`; anything but exactly ``hold`` is ``deny``."""
1348
+
1349
+ if command_policy.get("permission_fallback") == PermissionFallback.HOLD.value:
1350
+ return PermissionFallback.HOLD
1351
+ return PermissionFallback.DENY
1352
+
1353
+
1299
1354
  def _contract_workspace_path(
1300
1355
  *,
1301
1356
  workspace_root: Path,
@@ -1992,6 +2047,7 @@ class RunnerRalphActivities:
1992
2047
  mcp_servers=attempt.mcp_servers,
1993
2048
  egress_allow_list=attempt.egress_allow_list,
1994
2049
  auth_mode=attempt.auth_mode,
2050
+ permission_fallback=runtime_state.permission_fallback,
1995
2051
  prompt=attempt.skills_preamble
1996
2052
  + _directive_prompt(
1997
2053
  completion_criteria=runtime_state.completion_criteria,
@@ -3363,6 +3419,7 @@ class RunnerRalphActivities:
3363
3419
  cli_kind=runtime_context.cli_kind,
3364
3420
  model=runtime_context.model,
3365
3421
  memory_limit_bytes=_profile_memory_limit_bytes(runtime_context.command_policy),
3422
+ permission_fallback=_profile_permission_fallback(runtime_context.command_policy),
3366
3423
  grant_snapshot=snapshot,
3367
3424
  grant_snapshot_payload=snapshot_payload,
3368
3425
  persona_slug=runtime_context.persona_slug,
@@ -4636,6 +4693,7 @@ class RunnerRalphActivities:
4636
4693
  mcp_servers=attempt.mcp_servers,
4637
4694
  egress_allow_list=attempt.egress_allow_list,
4638
4695
  auth_mode=attempt.auth_mode,
4696
+ permission_fallback=state.permission_fallback,
4639
4697
  prompt=attempt.skills_preamble
4640
4698
  + _learning_prompt(
4641
4699
  request,
@@ -4896,6 +4954,7 @@ class RunnerRalphActivities:
4896
4954
  mcp_servers=attempt.mcp_servers,
4897
4955
  egress_allow_list=attempt.egress_allow_list,
4898
4956
  auth_mode=attempt.auth_mode,
4957
+ permission_fallback=state.permission_fallback,
4899
4958
  prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
4900
4959
  base_branch=request.base_ref,
4901
4960
  work_branch=state.work_branch,
@@ -5155,38 +5214,41 @@ class RunnerRalphActivities:
5155
5214
  if auth_mode == AuthMode.API_KEY:
5156
5215
  return
5157
5216
  directive = directive_id(work_record_id=work_record_id, directive_number=directive_number)
5158
- if runtime_state.cli_kind == "claude_code":
5217
+ reports: list[tuple[dict[str, Any], str | None]]
5218
+ if result.model_usage:
5219
+ reports = _model_usage_reports(runtime_state.cli_kind, result.model_usage)
5220
+ elif runtime_state.cli_kind == "claude_code":
5159
5221
  usage_event = extract_claude_result(result.stdout)
5160
- model_name = None
5222
+ reports = [(usage_event, None)] if usage_event else []
5161
5223
  else:
5162
5224
  usage_event = extract_codex_turn_usage(result.stdout)
5163
- model_name = runtime_state.model or None
5164
- if not usage_event:
5165
- return
5166
- if runtime_state.cli_kind != "claude_code" and model_name is None:
5167
- # The backend 422s a Codex harness report with no model (`report_harness_usage`
5168
- # requires one to price it), and the `suppress` below swallows that -- an Agent
5169
- # with no model configured would otherwise lose this Usage Record with no trace.
5170
- activity.logger.warning(
5171
- "harness usage unreported: Agent %s has no model configured for a Codex "
5172
- "harness Directive (work record %s)",
5173
- runtime_state.agent_id,
5174
- work_record_id,
5175
- )
5176
- with contextlib.suppress(Exception): # noqa: BLE001 - metering never fails a Directive
5177
- await self._fastapi_client.report_harness_usage(
5178
- {
5179
- "work_record_id": work_record_id,
5180
- "agent_id": runtime_state.agent_id,
5181
- "contract_id": runtime_state.contract_id,
5182
- "directive_id": directive,
5183
- "runner_id": None,
5184
- "runtime_kind": runtime_state.cli_kind,
5185
- "provider_name": runtime_state.cli_kind,
5186
- "model_name": model_name,
5187
- "usage_event": usage_event,
5188
- }
5189
- )
5225
+ reports = [(usage_event, runtime_state.model or None)] if usage_event else []
5226
+ for usage_event, model_name in reports:
5227
+ if runtime_state.cli_kind != "claude_code" and model_name is None:
5228
+ # The backend 422s a Codex harness report with no model
5229
+ # (`report_harness_usage` requires one to price it), and the `suppress`
5230
+ # below swallows that -- an Agent with no model configured would otherwise
5231
+ # lose this Usage Record with no trace.
5232
+ activity.logger.warning(
5233
+ "harness usage unreported: Agent %s has no model configured for a Codex "
5234
+ "harness Directive (work record %s)",
5235
+ runtime_state.agent_id,
5236
+ work_record_id,
5237
+ )
5238
+ with contextlib.suppress(Exception): # noqa: BLE001 - metering never fails a Directive
5239
+ await self._fastapi_client.report_harness_usage(
5240
+ {
5241
+ "work_record_id": work_record_id,
5242
+ "agent_id": runtime_state.agent_id,
5243
+ "contract_id": runtime_state.contract_id,
5244
+ "directive_id": directive,
5245
+ "runner_id": None,
5246
+ "runtime_kind": runtime_state.cli_kind,
5247
+ "provider_name": runtime_state.cli_kind,
5248
+ "model_name": model_name,
5249
+ "usage_event": usage_event,
5250
+ }
5251
+ )
5190
5252
 
5191
5253
  async def _harness_hold(
5192
5254
  self,
@@ -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
@@ -99,9 +99,12 @@ from agentic_runner.sealed_box import (
99
99
  from agentic_runner.tiny_http import HttpRequest, read_request, write_json
100
100
  from agentic_runner.triage_activities import RunnerTriageActivities
101
101
  from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
102
+ from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
102
103
  from agentic_runner.workers.agent_runtime import AgentRuntime
103
104
  from agentic_runner.workers.claude_runtime import ClaudeRuntime
105
+ from agentic_runner.workers.claude_sign_in import ClaudeSignIns
104
106
  from agentic_runner.workers.codex_runtime import CodexRuntime
107
+ from agentic_runner.workers.contract_device_login import ContractDeviceLogin
105
108
  from agentic_runner.workers.contract_isolation import UID_MAP_FILENAME, ContractIsolation
106
109
  from agentic_runner.workers.fastapi_client import DirectiveTokenSource, RunnerFastApiClient
107
110
  from agentic_runner.workers.settings import WorkerSettings, get_worker_settings
@@ -123,7 +126,11 @@ from agentic_runner_contracts.runner_registration import (
123
126
  ResourcePressure,
124
127
  )
125
128
  from agentic_runner_contracts.runtime_context import WorkerRuntimeContextResolver
126
- 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
+ )
127
134
 
128
135
  __all__ = [
129
136
  "AGENT_TOKEN_ENV",
@@ -388,10 +395,18 @@ def build_agent_runtimes(
388
395
  picks from here by the Work Record's `cli_kind` (local-agents 01) -- there is no
389
396
  process-wide choice. The same `clis` ride the heartbeat's attestation, so what this
390
397
  Runner serves and what routing believes it serves are one list.
398
+
399
+ Which runtime serves a kind -- its pinned ACP bridge or the per-CLI one -- is this
400
+ Runner's setting (`ACP_CLI_KINDS`), never the payload's (local-agents 12 items 6, 7).
391
401
  """
392
402
 
403
+ acp_kinds = {kind.strip() for kind in settings.ACP_CLI_KINDS.split(",") if kind.strip()}
393
404
  return {
394
- cli.cli_kind: _RUNTIMES[cli.cli_kind](settings)
405
+ cli.cli_kind: (
406
+ AcpRuntime(cli_kind=cli.cli_kind, settings=settings)
407
+ if cli.cli_kind in acp_kinds and cli.cli_kind in ACP_BRIDGES
408
+ else _RUNTIMES[cli.cli_kind](settings)
409
+ )
395
410
  for cli in clis
396
411
  if cli.meets_floor and cli.cli_kind in _RUNTIMES
397
412
  }
@@ -568,6 +583,11 @@ class ControlPlaneStream:
568
583
  self_tests: HarnessSelfTests | None = None
569
584
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
570
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)
571
591
  _delivered: set[tuple[str, str]] = field(default_factory=set)
572
592
  _offered_to_proxy: dict[tuple[str, str], str] = field(default_factory=dict)
573
593
  _last_seen: tuple[float, datetime] | None = None
@@ -670,11 +690,14 @@ class ControlPlaneStream:
670
690
  clock_skew_seconds=self._clock_skew(now),
671
691
  source_status=self.sources.statuses() if self.sources is not None else [],
672
692
  tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
693
+ sign_in_codes=self._sign_in_outcomes,
673
694
  )
695
+ self._sign_in_outcomes = []
674
696
  ack = await self.client.heartbeat(self.state, envelope)
675
697
  if self.sources is not None:
676
698
  self.sources.assign(ack.user_sources)
677
699
  await self._deliver_transcripts(ack)
700
+ await self._relay_sign_in_codes(ack)
678
701
  if self.lifecycle is not None:
679
702
  self.lifecycle.acknowledge(lifecycle)
680
703
  if self.heartbeat_stamp is not None:
@@ -721,6 +744,28 @@ class ControlPlaneStream:
721
744
  "transcript %s could not be delivered: %s", request.request_id, error
722
745
  )
723
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
+
724
769
  def _sync_delivered(self) -> None:
725
770
  """The resolver holds exactly what the last ack let this installation open."""
726
771
 
@@ -1010,6 +1055,8 @@ async def _serve_registered(
1010
1055
  )
1011
1056
  messages = MessageStore(state_dir / MESSAGES_DIR)
1012
1057
  stream.messages = messages
1058
+ sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can_change_uid))
1059
+ stream.sign_ins = sign_ins
1013
1060
  tool_servers = ToolServerHealthLog()
1014
1061
  stream.tool_servers = tool_servers
1015
1062
  link = HeartbeatLink(stream, ceilings=proxy.ceilings)
@@ -1056,6 +1103,7 @@ async def _serve_registered(
1056
1103
  token_source=stream,
1057
1104
  messages=messages,
1058
1105
  tool_servers=tool_servers,
1106
+ sign_ins=sign_ins,
1059
1107
  # The wake signal rides the connection this process polls with: one
1060
1108
  # namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
1061
1109
  signaller=TemporalWorkflowSignaller(client),
@@ -1083,6 +1131,7 @@ async def _serve_registered(
1083
1131
  task.cancel()
1084
1132
  with contextlib.suppress(asyncio.CancelledError):
1085
1133
  await task
1134
+ await sign_ins.close()
1086
1135
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
1087
1136
  await _report_stop(link, lifecycle)
1088
1137
  return 1 if revoked.is_set() else 0
@@ -1195,6 +1244,7 @@ def _activities(
1195
1244
  messages: MessageStore | None = None,
1196
1245
  signaller: TemporalWorkflowSignaller | None = None,
1197
1246
  tool_servers: ToolServerHealthLog | None = None,
1247
+ sign_ins: ClaudeSignIns | None = None,
1198
1248
  ) -> list[Callable[..., Any]]:
1199
1249
  github_client = GitHubAppClient(
1200
1250
  app_id=os.getenv("GITHUB_APP_ID"),
@@ -1246,7 +1296,9 @@ def _activities(
1246
1296
  return [
1247
1297
  *ralph.activity_callables(),
1248
1298
  *ContractDeviceLoginActivities(
1249
- 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,
1250
1302
  ).activity_callables(),
1251
1303
  *RunnerTriageActivities(
1252
1304
  proxy=proxy,
@@ -37,7 +37,7 @@ from dataclasses import dataclass, field
37
37
  from datetime import UTC, datetime, timedelta
38
38
  from email.utils import formatdate
39
39
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40
- from typing import Any, Literal
40
+ from typing import Any
41
41
  from uuid import UUID, uuid4
42
42
 
43
43
  import httpx
@@ -166,7 +166,7 @@ class FakeControlPlane:
166
166
  contracts_floor: str = contracts_version
167
167
  contract_id: UUID = DEFAULT_CONTRACT_ID
168
168
  agent_id: UUID = DEFAULT_AGENT_ID
169
- cli_kind: Literal["codex_cli", "claude_code"] = "codex_cli"
169
+ cli_kind: str = "codex_cli"
170
170
 
171
171
  bootstraps: list[BootstrapRequest] = field(default_factory=list)
172
172
  runner_ids: list[UUID] = field(default_factory=list)
@@ -42,7 +42,10 @@ from agentic_runner.workers.contract_isolation import DirectiveSandbox
42
42
  # the funder anyway, or route it round the Profile's egress allow-list. The two Claude Code
43
43
  # bearers are here for the last reason too: either would authenticate the harness on a
44
44
  # credential the Runner never chose, around the proxy and the mode it decided
45
- # (local-agents 04; research 01 §4 gap 6).
45
+ # (local-agents 04; research 01 §4 gap 6). The ACP bridges' control variables are here
46
+ # for the same reason (local-agents 12): each swaps the vendor binary, hands approvals
47
+ # back to the harness's own reviewer, authenticates the bridge, or re-enables the MCP
48
+ # filtering that drops a granted server.
46
49
  RESERVED_DIRECTIVE_ENV: Final[frozenset[str]] = frozenset(
47
50
  {
48
51
  "HOME",
@@ -53,6 +56,20 @@ RESERVED_DIRECTIVE_ENV: Final[frozenset[str]] = frozenset(
53
56
  "ANTHROPIC_API_KEY",
54
57
  "ANTHROPIC_AUTH_TOKEN",
55
58
  "CLAUDE_CODE_OAUTH_TOKEN",
59
+ "CODEX_PATH",
60
+ "CODEX_CONFIG",
61
+ "CODEX_API_KEY",
62
+ "MODEL_PROVIDER",
63
+ "DEFAULT_AUTH_REQUEST",
64
+ "INITIAL_AGENT_MODE",
65
+ "NO_BROWSER",
66
+ "APP_SERVER_LOGS",
67
+ "DISABLE_MCP_CONFIG_FILTERING",
68
+ "CLAUDE_CODE_EXECUTABLE",
69
+ "CLAUDE_MODEL_CONFIG",
70
+ "CLAUDE_AGENT_LOGS",
71
+ "MAX_THINKING_TOKENS",
72
+ "ANTHROPIC_MODEL",
56
73
  CALLBACK_SOCKET_ENV,
57
74
  CALLBACK_TOKEN_ENV,
58
75
  *PROXY_ENV_NAMES,
@@ -154,10 +171,10 @@ async def run_subprocess_exec(
154
171
  timeout=timeout_seconds,
155
172
  )
156
173
  except (TimeoutError, asyncio.CancelledError):
157
- await _terminate_process_tree(process)
174
+ await terminate_process_tree(process)
158
175
  raise
159
176
  except PriorAttemptAliveError:
160
- await _terminate_process_tree(process)
177
+ await terminate_process_tree(process)
161
178
  raise
162
179
 
163
180
  return SubprocessResult(
@@ -240,7 +257,7 @@ async def _read_stream_limited(
240
257
  return bytes(tail), total_bytes > limit_bytes
241
258
 
242
259
 
243
- async def _terminate_process_tree(process: asyncio.subprocess.Process) -> None:
260
+ async def terminate_process_tree(process: asyncio.subprocess.Process) -> None:
244
261
  if process.returncode is not None:
245
262
  return
246
263
  with contextlib.suppress(ProcessLookupError, PermissionError):
@@ -379,7 +396,7 @@ async def run_subprocess_launch_and_detach(
379
396
  raise RuntimeError(f"process failed to start: {error.__class__.__name__}") from None
380
397
 
381
398
  if process.stdout is None:
382
- await _terminate_process_tree(process)
399
+ await terminate_process_tree(process)
383
400
  raise RuntimeError("process stdout stream was unavailable")
384
401
 
385
402
  loop = asyncio.get_running_loop()
@@ -408,7 +425,7 @@ async def run_subprocess_launch_and_detach(
408
425
  _detach(process)
409
426
  return text
410
427
  except BaseException:
411
- await _terminate_process_tree(process)
428
+ await terminate_process_tree(process)
412
429
  raise
413
430
 
414
431