agentic-runner 3.8.0__tar.gz → 3.9.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 (74) hide show
  1. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/activities.py +53 -1
  4. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/service.py +38 -3
  5. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/_runtime_support.py +1 -0
  6. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/acp_runtime.py +123 -19
  7. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/agent_runtime.py +3 -0
  8. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/settings.py +10 -0
  9. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/.gitignore +0 -0
  10. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/LICENSE +0 -0
  11. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/README.md +0 -0
  12. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/pyproject.toml +0 -0
  13. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/attempts.py +0 -0
  14. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/auth_mode.py +0 -0
  15. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/build.py +0 -0
  16. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/callback.py +0 -0
  17. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/child_watcher.py +0 -0
  18. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/cli.py +0 -0
  19. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/cli_floor.py +0 -0
  20. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/config.py +0 -0
  21. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/credentials.py +0 -0
  22. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/device_login_activities.py +0 -0
  23. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/egress.py +0 -0
  24. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/harness_self_test.py +0 -0
  25. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/heartbeat_link.py +0 -0
  26. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/hooks.py +0 -0
  27. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/host_store.py +0 -0
  28. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/__init__.py +0 -0
  29. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  30. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  31. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  32. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  33. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  34. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  35. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  36. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  37. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  38. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  39. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  40. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/lifecycle.py +0 -0
  41. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/llm_proxy.py +0 -0
  42. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/mcp.py +0 -0
  43. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/message_store.py +0 -0
  44. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/oauth_connectors.py +0 -0
  45. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/private_state.py +0 -0
  46. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/py.typed +0 -0
  47. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  48. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/registration.py +0 -0
  49. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/runtime/__init__.py +0 -0
  50. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  51. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/sealed_box.py +0 -0
  52. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/__init__.py +0 -0
  53. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/__main__.py +0 -0
  54. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/control_plane.py +0 -0
  55. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/plugin.py +0 -0
  56. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  57. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/workflow.py +0 -0
  58. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/tiny_http.py +0 -0
  59. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/triage_activities.py +0 -0
  60. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/usage_windows.py +0 -0
  61. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/user_sources.py +0 -0
  62. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/__init__.py +0 -0
  63. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  64. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  65. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  66. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/command_policy.py +0 -0
  67. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  68. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  69. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  70. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  71. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  72. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  73. {agentic_runner-3.8.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/skills.py +0 -0
  74. {agentic_runner-3.8.0 → agentic_runner-3.9.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.8.0
3
+ Version: 3.9.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.8.0"
10
+ __version__ = "3.9.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -124,6 +124,7 @@ from agentic_runner.workers._runtime_support import (
124
124
  bound_text,
125
125
  bound_text_tail,
126
126
  )
127
+ from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
127
128
  from agentic_runner.workers.agent_runtime import (
128
129
  AgentRuntime,
129
130
  AuthMode,
@@ -595,6 +596,8 @@ class _RuntimeContextState:
595
596
  # PRD decision 12: what a runtime asked per command does when the command policy has
596
597
  # no answer. Carried on the same `command_policy` blob as the ceiling above.
597
598
  permission_fallback: PermissionFallback = PermissionFallback.DENY
599
+ # Local-agents 18: the Profile-named ACP command, for a kind with no pinned bridge.
600
+ acp_command: tuple[str, ...] = ()
598
601
  # Pushed on change and applied on receipt (ADR-0011 §11, PRD issue 44): the evaluator
599
602
  # is local, so a narrowing lands on the next verb this state is read for.
600
603
  grant_snapshot: GrantSnapshot = UNENFORCED_SNAPSHOT
@@ -2053,6 +2056,7 @@ class RunnerRalphActivities:
2053
2056
  egress_allow_list=attempt.egress_allow_list,
2054
2057
  auth_mode=attempt.auth_mode,
2055
2058
  permission_fallback=runtime_state.permission_fallback,
2059
+ acp_command=runtime_state.acp_command,
2056
2060
  prompt=attempt.skills_preamble
2057
2061
  + _directive_prompt(
2058
2062
  completion_criteria=runtime_state.completion_criteria,
@@ -3008,10 +3012,55 @@ class RunnerRalphActivities:
3008
3012
  Fails closed (local-agents 01): a kind this process does not serve fails the
3009
3013
  Directive non-retryably, with Evidence naming the kind and the Runner, and never
3010
3014
  falls back to another runtime -- routing should not have sent it here, and a
3011
- retry on this Runner would find the same registry.
3015
+ retry on this Runner would find the same registry. So does a Profile-named ACP
3016
+ command for a kind whose bridge the Runner pins (local-agents 18): the platform
3017
+ never sends one, and the pinned bridge is the only thing that runs for that kind --
3018
+ and, for any other kind, a command whose ``argv[0]`` is not the executable the host
3019
+ pinned for it.
3012
3020
  """
3013
3021
 
3022
+ if state.acp_command and state.cli_kind in ACP_BRIDGES:
3023
+ await _raise_recorded(
3024
+ ApplicationError(
3025
+ f"the Runner pins the ACP bridge for {state.cli_kind!r}; a Profile-named "
3026
+ "ACP command is refused for it",
3027
+ non_retryable=True,
3028
+ ),
3029
+ self._fastapi_client,
3030
+ work_record_id,
3031
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3032
+ actor=self._actor(),
3033
+ payload={
3034
+ "event": "directive.acp_command_refused",
3035
+ "cli_kind": state.cli_kind,
3036
+ # The executable's name only: its arguments may carry a secret.
3037
+ "executable": redact_secret_like_text(Path(state.acp_command[0]).name),
3038
+ },
3039
+ )
3014
3040
  runtime = self._agent_runtimes.get(state.cli_kind)
3041
+ command_refusal = (
3042
+ runtime.acp_command_refusal(state.acp_command)
3043
+ if isinstance(runtime, AcpRuntime)
3044
+ else None
3045
+ )
3046
+ if command_refusal is not None:
3047
+ await _raise_recorded(
3048
+ ApplicationError(command_refusal, non_retryable=True),
3049
+ self._fastapi_client,
3050
+ work_record_id,
3051
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3052
+ actor=self._actor(),
3053
+ payload={
3054
+ "event": "directive.acp_command_refused",
3055
+ "cli_kind": state.cli_kind,
3056
+ "executable": (
3057
+ redact_secret_like_text(Path(state.acp_command[0]).name)
3058
+ if state.acp_command
3059
+ else None
3060
+ ),
3061
+ "reason": command_refusal,
3062
+ },
3063
+ )
3015
3064
  if runtime is not None:
3016
3065
  return runtime
3017
3066
  served = sorted(self._agent_runtimes)
@@ -3425,6 +3474,7 @@ class RunnerRalphActivities:
3425
3474
  model=runtime_context.model,
3426
3475
  memory_limit_bytes=_profile_memory_limit_bytes(runtime_context.command_policy),
3427
3476
  permission_fallback=_profile_permission_fallback(runtime_context.command_policy),
3477
+ acp_command=tuple(runtime_context.acp_command or ()),
3428
3478
  grant_snapshot=snapshot,
3429
3479
  grant_snapshot_payload=snapshot_payload,
3430
3480
  persona_slug=runtime_context.persona_slug,
@@ -4699,6 +4749,7 @@ class RunnerRalphActivities:
4699
4749
  egress_allow_list=attempt.egress_allow_list,
4700
4750
  auth_mode=attempt.auth_mode,
4701
4751
  permission_fallback=state.permission_fallback,
4752
+ acp_command=state.acp_command,
4702
4753
  prompt=attempt.skills_preamble
4703
4754
  + _learning_prompt(
4704
4755
  request,
@@ -4960,6 +5011,7 @@ class RunnerRalphActivities:
4960
5011
  egress_allow_list=attempt.egress_allow_list,
4961
5012
  auth_mode=attempt.auth_mode,
4962
5013
  permission_fallback=state.permission_fallback,
5014
+ acp_command=state.acp_command,
4963
5015
  prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
4964
5016
  base_branch=request.base_ref,
4965
5017
  work_branch=state.work_branch,
@@ -107,7 +107,7 @@ from agentic_runner.usage_windows import (
107
107
  UsageWindows,
108
108
  )
109
109
  from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
110
- from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
110
+ from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime, profile_acp_executables
111
111
  from agentic_runner.workers.agent_runtime import AgentRuntime
112
112
  from agentic_runner.workers.claude_runtime import ClaudeRuntime
113
113
  from agentic_runner.workers.claude_sign_in import ClaudeSignIns
@@ -165,6 +165,7 @@ __all__ = [
165
165
  "run",
166
166
  "run_runner_lifecycle_hook",
167
167
  "run_worker_until_terminated",
168
+ "with_profile_acp_cli_kinds",
168
169
  ]
169
170
 
170
171
  _logger = logging.getLogger(__name__)
@@ -406,20 +407,49 @@ def build_agent_runtimes(
406
407
 
407
408
  Which runtime serves a kind -- its pinned ACP bridge or the per-CLI one -- is this
408
409
  Runner's setting (`ACP_CLI_KINDS`), never the payload's (local-agents 12 items 6, 7).
410
+ A kind the host opted in to run a Profile-named command for (`ACP_PROFILE_CLI_KINDS`,
411
+ local-agents 18) reaches `clis` through :func:`with_profile_acp_cli_kinds`.
409
412
  """
410
413
 
411
414
  acp_kinds = {kind.strip() for kind in settings.ACP_CLI_KINDS.split(",") if kind.strip()}
415
+ profile_kinds = set(profile_acp_executables(settings))
412
416
  return {
413
417
  cli.cli_kind: (
414
418
  AcpRuntime(cli_kind=cli.cli_kind, settings=settings)
415
- if cli.cli_kind in acp_kinds and cli.cli_kind in ACP_BRIDGES
419
+ if cli.cli_kind in profile_kinds
420
+ or (cli.cli_kind in acp_kinds and cli.cli_kind in ACP_BRIDGES)
416
421
  else _RUNTIMES[cli.cli_kind](settings)
417
422
  )
418
423
  for cli in clis
419
- if cli.meets_floor and cli.cli_kind in _RUNTIMES
424
+ if cli.meets_floor and (cli.cli_kind in _RUNTIMES or cli.cli_kind in profile_kinds)
420
425
  }
421
426
 
422
427
 
428
+ def with_profile_acp_cli_kinds(
429
+ attestation: HostAttestation | None, settings: WorkerSettings
430
+ ) -> HostAttestation | None:
431
+ """The attestation, plus each kind the host opted in to run a Profile-named ACP command
432
+ for, so routing sends that kind here and the registry serves it (local-agents 18).
433
+
434
+ The Runner does not start the host's executable just to ask its version, so the
435
+ version reported is the Runner's own: what serves the kind is this build's ACP runtime.
436
+ No attestation stays none -- such a Runner is routed nothing at all.
437
+ """
438
+
439
+ if attestation is None:
440
+ return None
441
+ found = {cli.cli_kind for cli in attestation.clis}
442
+ added = [
443
+ CliVersion(cli_kind=kind, version=runner_version, meets_floor=True)
444
+ for kind in profile_acp_executables(settings)
445
+ if kind not in found
446
+ ]
447
+ # Validated, not copied: the attestation's cap on `clis` holds for these too.
448
+ return HostAttestation.model_validate(
449
+ {**attestation.model_dump(), "clis": [*attestation.clis, *added]}
450
+ )
451
+
452
+
423
453
  async def run_runner_lifecycle_hook(hooks: HookRunner, name: HookName) -> None:
424
454
  """``runner_startup`` / ``runner_shutdown``: the two slots outside a Directive.
425
455
 
@@ -927,6 +957,11 @@ async def run(
927
957
  # with, unless the operator points them at an internal address explicitly.
928
958
  os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
929
959
  settings = get_worker_settings()
960
+ try:
961
+ attestation = with_profile_acp_cli_kinds(attestation, settings)
962
+ except ValueError as error:
963
+ print(f"refusing to start: {error}", flush=True)
964
+ return 1
930
965
  state_dir = config.state_dir
931
966
 
932
967
  readiness = readiness or Readiness(int(os.environ.get(READINESS_PORT_ENV, "").strip() or 0))
@@ -51,6 +51,7 @@ RESERVED_DIRECTIVE_ENV: Final[frozenset[str]] = frozenset(
51
51
  "HOME",
52
52
  "PATH",
53
53
  "TMPDIR",
54
+ "XDG_CONFIG_HOME",
54
55
  "CODEX_HOME",
55
56
  "CLAUDE_CONFIG_DIR",
56
57
  "ANTHROPIC_API_KEY",
@@ -35,6 +35,17 @@ Facts this module stands on (the LA-02 spike, re-read against the pinned bridge
35
35
  (LA-19): nothing may load that the Runner did not choose.
36
36
  - claude-agent-acp loads the Workspace's settings, hooks, ``.mcp.json`` and ``CLAUDE.md``
37
37
  unless ``session/new`` carries the three options in ``_CLAUDE_SESSION_OPTIONS``.
38
+
39
+ A ``cli_kind`` with no pinned bridge (local-agents 18) runs the command its Agent Runtime
40
+ Profile names, and only on a Runner whose host pinned that kind's executable in
41
+ ``ACP_PROFILE_CLI_KINDS`` (``gemini_cli=/usr/local/bin/gemini``). The Profile supplies the
42
+ arguments, never the program: an ``argv[0]`` that is not exactly the host-pinned path is
43
+ refused, or whoever edits the Profile would choose what runs on the host -- a shell, or a
44
+ binary the repository ships. None of the facts above are known for that harness. It gets
45
+ exactly a pinned bridge's isolation -- the Contract's uid,
46
+ ``DirectiveSandbox``, the reserved env, the granted MCP servers, the egress proxy and the
47
+ permission seam -- with the harness root as ``XDG_CONFIG_HOME``, the one config-root
48
+ convention the Runner can assume of a harness it does not know.
38
49
  """
39
50
 
40
51
  from __future__ import annotations
@@ -86,9 +97,10 @@ from agentic_runner.workers.command_policy import CommandPolicy, evaluate_comman
86
97
  from agentic_runner.workers.contract_isolation import DirectiveSandbox
87
98
  from agentic_runner.workers.mcp_config import McpServerEntry
88
99
  from agentic_runner.workers.settings import WorkerSettings
100
+ from agentic_runner_contracts.redaction import redact_secret_like_text
89
101
  from agentic_runner_contracts.runner_registration import UsageWindow
90
102
 
91
- __all__ = ["ACP_BRIDGES", "AcpBridge", "AcpRuntime"]
103
+ __all__ = ["ACP_BRIDGES", "AcpBridge", "AcpRuntime", "profile_acp_executables"]
92
104
 
93
105
 
94
106
  @dataclass(frozen=True, slots=True)
@@ -127,6 +139,41 @@ ACP_BRIDGES: Final[Mapping[str, AcpBridge]] = {
127
139
  ),
128
140
  }
129
141
 
142
+ _CLI_KIND_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
143
+
144
+
145
+ def profile_acp_executables(settings: WorkerSettings) -> Mapping[str, str]:
146
+ """Each kind this host opted in to run a Profile-named ACP command for, to the absolute
147
+ path of the one executable it trusts for that kind (local-agents 18).
148
+
149
+ A pinned kind, a malformed one, a kind named twice or a path that is not absolute is a
150
+ ValueError, so a typo stops the Runner starting instead of leaving a kind silently
151
+ unserved or served by whatever a relative name resolves to.
152
+ """
153
+
154
+ executables: dict[str, str] = {}
155
+ for entry in (part.strip() for part in settings.ACP_PROFILE_CLI_KINDS.split(",")):
156
+ if not entry:
157
+ continue
158
+ kind, separator, executable = (part.strip() for part in entry.partition("="))
159
+ if kind in ACP_BRIDGES:
160
+ raise ValueError(
161
+ f"ACP_PROFILE_CLI_KINDS names {kind!r}, whose ACP bridge the Runner pins; "
162
+ "a Profile never names the command for it"
163
+ )
164
+ if not _CLI_KIND_PATTERN.fullmatch(kind):
165
+ raise ValueError(f"ACP_PROFILE_CLI_KINDS names {kind!r}, which is not a cli_kind")
166
+ if not separator or not Path(executable).is_absolute():
167
+ raise ValueError(
168
+ f"ACP_PROFILE_CLI_KINDS must pin {kind!r} to an absolute executable path "
169
+ f"({kind}=/path/to/executable)"
170
+ )
171
+ if kind in executables:
172
+ raise ValueError(f"ACP_PROFILE_CLI_KINDS names {kind!r} twice")
173
+ executables[kind] = executable
174
+ return dict(sorted(executables.items()))
175
+
176
+
130
177
  _ACP_PROTOCOL_VERSION: Final[int] = 1
131
178
  # ACP frames are single JSON lines; a tool call carrying a file diff runs far past
132
179
  # asyncio's 64 KiB default line limit.
@@ -224,7 +271,8 @@ class _Turn:
224
271
 
225
272
 
226
273
  class AcpRuntime:
227
- """An Agent Runtime that serves one ``cli_kind`` through its pinned ACP bridge."""
274
+ """An Agent Runtime that serves one ``cli_kind`` through its pinned ACP bridge, or, for
275
+ a kind with none, through the command the Directive's Profile names."""
228
276
 
229
277
  auth_modes = frozenset({AuthMode.API_KEY, AuthMode.SUBSCRIPTION})
230
278
  # Never a key of its own: an `api_key` Directive runs on the attempt's proxy pair.
@@ -239,7 +287,10 @@ class AcpRuntime:
239
287
  ask: Ask = ask_over_callback,
240
288
  ) -> None:
241
289
  self._cli_kind = cli_kind
242
- self._bridge = ACP_BRIDGES[cli_kind]
290
+ self._bridge = ACP_BRIDGES.get(cli_kind)
291
+ self._profile_executable = (
292
+ None if self._bridge is not None else profile_acp_executables(settings).get(cli_kind)
293
+ )
243
294
  self._settings = settings
244
295
  # Tests drive a fake ACP agent through this; production runs the pinned bridge.
245
296
  self._bridge_argv = list(bridge_argv) if bridge_argv is not None else None
@@ -249,6 +300,37 @@ class AcpRuntime:
249
300
  def _is_codex(self) -> bool:
250
301
  return self._cli_kind == "codex_cli"
251
302
 
303
+ @property
304
+ def _is_claude(self) -> bool:
305
+ return self._cli_kind == "claude_code"
306
+
307
+ @property
308
+ def _executable(self) -> str:
309
+ return self._bridge.executable if self._bridge is not None else self._cli_kind
310
+
311
+ def acp_command_refusal(self, acp_command: Sequence[str]) -> str | None:
312
+ """Why this runtime will not run the Profile-named ``acp_command``, or None.
313
+
314
+ None for a pinned kind: its bridge runs and the Profile's command is never read
315
+ (the activity refuses one sent for it). For any other kind the host's pinned
316
+ executable must be exactly ``argv[0]``; the Profile chooses only the arguments.
317
+ """
318
+
319
+ if self._bridge is not None:
320
+ return None
321
+ if not acp_command:
322
+ return f"the Agent Runtime Profile names no ACP command for {self._cli_kind}"
323
+ if self._profile_executable is None:
324
+ return f"this Runner's host pinned no ACP executable for {self._cli_kind}"
325
+ if not Path(acp_command[0]).is_absolute():
326
+ return f"the Profile's ACP command for {self._cli_kind} is not an absolute path"
327
+ if acp_command[0] != self._profile_executable:
328
+ return (
329
+ f"the Profile's ACP command for {self._cli_kind} is not the executable this "
330
+ "Runner's host pinned for it"
331
+ )
332
+ return None
333
+
252
334
  @property
253
335
  def _timeout_seconds(self) -> int:
254
336
  if self._is_codex:
@@ -285,8 +367,10 @@ class AcpRuntime:
285
367
  return self._refused(request, directive_workspace_id, guard_mode, error)
286
368
 
287
369
  extra_env = dict(request.extra_env)
370
+ # The name `llm_proxy.attempt_env` gave this kind its endpoint under.
288
371
  proxy_url = extra_env.get(
289
- PROXY_BASE_URL_ENV if self._is_codex else "ANTHROPIC_BASE_URL", ""
372
+ "ANTHROPIC_BASE_URL" if self._cli_kind.startswith("claude") else PROXY_BASE_URL_ENV,
373
+ "",
290
374
  )
291
375
  if request.auth_mode == AuthMode.API_KEY and not proxy_url:
292
376
  # Without the proxy pair the only credential left for the harness is a login in
@@ -306,7 +390,11 @@ class AcpRuntime:
306
390
  if config_refusal:
307
391
  return refuse("refused: Codex config file", config_refusal)
308
392
 
309
- argv = self._argv(request.auth_mode)
393
+ # The activity refuses this first, with Evidence; checked again where it runs.
394
+ command_refusal = self.acp_command_refusal(request.acp_command)
395
+ if command_refusal is not None:
396
+ return refuse("refused: Profile ACP command", command_refusal)
397
+ argv = self._argv(request)
310
398
  command_hash = hash_command(argv)
311
399
  floor_refusal = command_policy_refusal(
312
400
  argv=argv,
@@ -319,18 +407,26 @@ class AcpRuntime:
319
407
  if floor_refusal is not None:
320
408
  return refuse(f"refused: command policy ({floor_refusal})", floor_refusal)
321
409
 
410
+ if self._bridge is not None:
411
+ runs = f"bridge={self._bridge.package}@{self._bridge.version}"
412
+ else:
413
+ # The executable's name and nothing of its arguments here; the note below
414
+ # carries them, scrubbed (local-agents 18 item 4).
415
+ command = redact_secret_like_text(Path(argv[0]).name)
416
+ runs = f"cli_kind={self._cli_kind}; command={command}"
322
417
  guard_mode = (
323
- f"runtime=acp; bridge={self._bridge.package}@{self._bridge.version}; "
324
- f"auth={request.auth_mode.value}; permission=command-policy; "
325
- f"fallback={request.permission_fallback.value}"
418
+ f"runtime=acp; {runs}; auth={request.auth_mode.value}; "
419
+ f"permission=command-policy; fallback={request.permission_fallback.value}"
326
420
  )
327
421
  turn = _Turn()
422
+ if self._bridge is None:
423
+ turn.notes.append(f"profile ACP command: {redact_secret_like_text(shlex.join(argv))}")
328
424
  timed_out = False
329
425
  async with contextlib.AsyncExitStack() as stack:
330
426
  env = self._env(request, harness_root)
331
427
  if self._is_codex and request.auth_mode == AuthMode.API_KEY:
332
428
  assert harness_root is not None # Codex always has one: see _harness_root
333
- env[self._bridge.config_root_env] = str(
429
+ env[ACP_BRIDGES["codex_cli"].config_root_env] = str(
334
430
  stack.enter_context(_attempt_codex_home(proxy_url, harness_root, sandbox))
335
431
  )
336
432
  apply_extra_env(env, request.extra_env)
@@ -343,7 +439,7 @@ class AcpRuntime:
343
439
  except TimeoutError:
344
440
  timed_out = True
345
441
  stop_reason, model_usage, stderr = "", (), ""
346
- error = f"{self._bridge.executable} timed out after {self._timeout_seconds}s"
442
+ error = f"{self._executable} timed out after {self._timeout_seconds}s"
347
443
  except OSError as exception:
348
444
  return self._result(
349
445
  request,
@@ -377,9 +473,13 @@ class AcpRuntime:
377
473
  model_usage=model_usage,
378
474
  )
379
475
 
380
- def _argv(self, auth_mode: AuthMode) -> list[str]:
476
+ def _argv(self, request: DirectiveRequest) -> list[str]:
477
+ if self._bridge is None:
478
+ # Only for a kind with no pinned bridge: for a pinned one the activity refuses a
479
+ # Profile-named command before it gets here, and this never reads it.
480
+ return list(request.acp_command)
381
481
  argv = list(self._bridge_argv) if self._bridge_argv else [self._bridge.executable]
382
- if not self._is_codex and auth_mode == AuthMode.API_KEY:
482
+ if self._is_claude and request.auth_mode == AuthMode.API_KEY:
383
483
  # The bridge's own guard: refuse any turn that would bill a claude.ai login.
384
484
  argv.append("--hide-claude-auth")
385
485
  return argv
@@ -395,12 +495,16 @@ class AcpRuntime:
395
495
  path = os.environ.get("PATH") or os.defpath
396
496
  env = {"PATH": path}
397
497
  if harness_root is not None:
398
- env[self._bridge.config_root_env] = str(harness_root)
498
+ config_root_env = (
499
+ self._bridge.config_root_env if self._bridge is not None else "XDG_CONFIG_HOME"
500
+ )
501
+ env[config_root_env] = str(harness_root)
399
502
  if request.sandbox is not None:
400
503
  env["HOME"] = str(request.sandbox.home_dir)
401
504
  env["TMPDIR"] = str(request.sandbox.tmp_dir)
402
- program = self._bridge.harness_program
403
- env[self._bridge.harness_path_env] = shutil.which(program, path=path) or program
505
+ if self._bridge is not None:
506
+ program = self._bridge.harness_program
507
+ env[self._bridge.harness_path_env] = shutil.which(program, path=path) or program
404
508
  if self._is_codex:
405
509
  env["INITIAL_AGENT_MODE"] = "workspace-write"
406
510
  env["NO_BROWSER"] = "1"
@@ -473,7 +577,7 @@ class AcpRuntime:
473
577
  "cwd": str(workspace_path),
474
578
  "mcpServers": acp_mcp_servers(request.mcp_servers or (), extra_env),
475
579
  }
476
- if not self._is_codex:
580
+ if self._is_claude:
477
581
  session_params["_meta"] = {
478
582
  "claudeCode": {
479
583
  "options": {**_CLAUDE_SESSION_OPTIONS, "model": self._settings.CLAUDE_MODEL}
@@ -481,7 +585,7 @@ class AcpRuntime:
481
585
  }
482
586
  session = await connection.request("session/new", session_params)
483
587
  session_id = str(session.get("sessionId") or "")
484
- if not self._is_codex and _offers_mode(session, _CLAUDE_MODE):
588
+ if self._is_claude and _offers_mode(session, _CLAUDE_MODE):
485
589
  await connection.request(
486
590
  "session/set_mode", {"sessionId": session_id, "modeId": _CLAUDE_MODE}
487
591
  )
@@ -492,7 +596,7 @@ class AcpRuntime:
492
596
  except AcpError as exception:
493
597
  return "", (), str(exception)
494
598
  except ConnectionError as exception:
495
- return "", (), str(exception) or f"{self._bridge.executable} closed its stdout"
599
+ return "", (), str(exception) or f"{self._executable} closed its stdout"
496
600
  return str(response.get("stopReason") or ""), _model_usage(response), ""
497
601
 
498
602
  def _handler(
@@ -561,7 +665,7 @@ class AcpRuntime:
561
665
  guard_mode: str,
562
666
  error: str,
563
667
  ) -> DirectiveResult:
564
- command_hash = hash_command([self._bridge.executable, "refused"])
668
+ command_hash = hash_command([self._executable, "refused"])
565
669
  return DirectiveResult(
566
670
  exit_code=126,
567
671
  stdout="",
@@ -103,6 +103,9 @@ class DirectiveRequest:
103
103
  auth_mode: AuthMode = AuthMode.API_KEY
104
104
  # Read only by a runtime the harness asks per command (the ACP runtime).
105
105
  permission_fallback: PermissionFallback = PermissionFallback.DENY
106
+ # The Profile-named ACP command (local-agents 18). Read only by the ACP runtime, and
107
+ # only for a `cli_kind` it pins no bridge for.
108
+ acp_command: tuple[str, ...] = ()
106
109
 
107
110
 
108
111
  @dataclass(frozen=True, slots=True)
@@ -140,6 +140,16 @@ class WorkerSettings(BaseSettings):
140
140
  "the pinned ACP bridge instead of the per-CLI runtime (local-agents 12)."
141
141
  ),
142
142
  )
143
+ ACP_PROFILE_CLI_KINDS: str = Field(
144
+ default="",
145
+ description=(
146
+ "Comma-separated `cli_kind=/absolute/executable` pairs, kinds other than "
147
+ "`codex_cli` and `claude_code`, for which this Runner's host opts in to run the "
148
+ "ACP command an Agent Runtime Profile names (local-agents 18). The Profile "
149
+ "supplies only the arguments: a command whose argv[0] is not exactly the pinned "
150
+ "executable is refused. Empty: no Profile-named command ever runs here."
151
+ ),
152
+ )
143
153
  CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS: bool = Field(
144
154
  default=False,
145
155
  description=(
File without changes
File without changes