autoforge-cli 0.1.0a0__py3-none-any.whl

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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/providers.py ADDED
@@ -0,0 +1,2893 @@
1
+ """Agent provider adapters: the only place that knows real CLI flags.
2
+
3
+ The engine speaks :class:`AgentRequest` / :class:`AgentExecutionResult`;
4
+ each provider translates a logical execution profile (provider, model,
5
+ effort, timeout, options) into a concrete non-interactive CLI invocation.
6
+
7
+ Verified against locally installed CLIs (2026-09):
8
+
9
+ Claude Code (``claude 2.1.x``; the stream run against 2.1.291 to 2.1.293)::
10
+
11
+ claude -p --output-format stream-json --verbose --model <alias|id>
12
+ --effort <low|medium|high|xhigh|max> --permission-mode <mode>
13
+ --no-session-persistence --safe-mode --strict-mcp-config
14
+ [extra_args] -- <prompt>
15
+
16
+ * ``-p/--print`` = non-interactive. With ``stream-json`` (the default,
17
+ #192) stdout is one JSON record per line while the agent runs
18
+ (``system`` init / thinking_tokens / api_retry, ``assistant`` content
19
+ blocks, ``user`` tool results, ``rate_limit_event``) and a ``result``
20
+ record that ends the turn, whose ``result`` field is its final
21
+ assistant text; a background task or a scheduled wakeup still pending
22
+ then keeps the CLI alive for another turn with its own ``result``
23
+ (held back to the end, in turn order, while a background agent runs).
24
+ The CLI refuses ``stream-json`` under ``--print`` without ``--verbose``.
25
+ The child runs under the duplex handle and the stream is reduced in
26
+ claude_stream.py: ``stdout`` of the result is the last ``result``
27
+ text verbatim (so the CONTROL_RESULT block survives untouched), the
28
+ other records become live progress (:mod:`autoforge.progress`), and an
29
+ error result in any turn, a result outside a turn, a malformed line or
30
+ a turn without its result is a
31
+ :attr:`AgentExecutionResult.provider_failure`.
32
+ * ``options.output_format: text`` is the opt-out: the final text is
33
+ printed once, at exit, and the run reports no progress and no activity
34
+ while it runs.
35
+ * ``--permission-mode bypassPermissions`` is required for unattended
36
+ edits and local git; ``--permission-prompts none`` would silently
37
+ deny anything that prompts. Configure via profile ``options``.
38
+ * nothing asks a person (#258; run on 2.1.312 against a loopback
39
+ stand-in of the Messages API, so no model was called): a tool call the
40
+ profile's mode does not allow, or that a deny rule of the user settings
41
+ names, is refused at once and the agent goes on without it (exit 0),
42
+ and ``AskUserQuestion`` is not offered under ``-p``. The stream says
43
+ which (claude_stream.py); the adapter names them in a run that fails
44
+ anyway and in the run log (:func:`_name_refused`). ``text`` output
45
+ carries none of it.
46
+ * ``--safe-mode`` and ``--strict-mcp-config`` (#165): no hook, installed
47
+ plugin or MCP server of the user's, the project's or a plugin's
48
+ configuration runs, so none can hand the agent a GitHub token of its
49
+ own; skills, custom agents and commands and CLAUDE.md auto-discovery
50
+ are off too (every phase prompt tells the agent to read ``AGENTS.md`` /
51
+ ``CLAUDE.md``). Checked against 2.1.296 with marker hooks and MCP
52
+ servers and no login (they run before the model is asked): user and
53
+ project settings, ``.mcp.json``, an installed marketplace plugin,
54
+ ``--settings``, ``--plugin-dir`` and ``--mcp-config`` all ran without
55
+ the flag and none ran with it, ``CLAUDE_CODE_SAFE_MODE=0`` set or not;
56
+ a real ``stream-json`` run is unchanged by it (the ``init`` record still
57
+ names the enabled plugins). Auth, model, tools and permission mode work
58
+ as before; managed (administrator) settings still apply.
59
+ ``--setting-sources=user``: no project or local settings, whose ``env``
60
+ the repository could use to undo the Bash tool's isolation (below).
61
+ :data:`CLAUDE_INTEGRATION_FLAGS` in ``extra_args`` are refused, and
62
+ ``autoforge doctor`` requires all three in ``claude --help``.
63
+ * the Bash tool runs no function or alias of a shell startup file (#165):
64
+ every launch sets ``CLAUDE_ENV_FILE`` to a file of its own holding
65
+ :data:`CLAUDE_SHELL_CLEANER`, ``CLAUDE_CODE_SHELL`` to a bash or zsh
66
+ and ``CLAUDE_CODE_SHELL_PREFIX`` to empty
67
+ (:meth:`ClaudeCodeProvider.integration_isolation`), and is refused while
68
+ the ``env`` of the user's settings or of the global configuration would
69
+ set a variable over them or over the controller's
70
+ (:func:`claude_configuration_problems`).
71
+ * exit code 0 on success; 1 on model/auth errors (message on stdout).
72
+
73
+ OpenCode (``opencode 2.0.x``, #186; run against 2.0.23)::
74
+
75
+ opencode run --standalone -m <provider/model>[#<effort>] --format default [--auto]
76
+ [extra_args] (message on stdin)
77
+
78
+ * the message travels on stdin, written whole and then closed: in argv,
79
+ v2 duplicates a message given after ``--`` and wraps one given without
80
+ it in quotes with ``"`` and backslashes escaped, and takes one that starts
81
+ with ``-`` for a flag (it prints the help and exits 0). stdin arrives
82
+ verbatim. A CLI that exits 0 without having read all of it is a
83
+ :attr:`AgentExecutionResult.provider_failure`.
84
+ * ``--standalone`` runs the agent on a private server that is the
85
+ client's own child; without it the run goes to a shared background
86
+ service (``opencode serve --service``) whose tools run outside this
87
+ invocation's process group, cwd and environment, and outlive it.
88
+ * model ids are ``provider/model`` (``openai/gpt-5.6-luna``); the
89
+ reasoning effort is the ``#<variant>`` suffix (v2 removed
90
+ ``--variant``), so a configured model carries no ``#`` of its own. An
91
+ unknown model or variant exits 1 with nothing on stdout.
92
+ * ``--format default`` prints only the final assistant text to stdout
93
+ (tool traces go to stderr). ``--format json`` is NOT used: on 2.0.23 it
94
+ emits ``step_start``, ``text``, ``tool_use`` and ``step_finish``
95
+ records, but a ``tool_use`` record only once the tool has completed (no
96
+ record when it starts) and no terminal record carrying the final text
97
+ or the outcome, so it could neither show a tool as it starts nor be
98
+ reduced to the CONTROL_RESULT text the way Claude's stream is (#192).
99
+ Live progress is therefore liveness only: each chunk on stdout or
100
+ stderr is an ``activity`` event, which prints nothing but keeps the
101
+ heartbeat's "last activity" current; the trace itself reaches the
102
+ operator only in ``stderr.log``.
103
+ * a run-level error prints ``ESC[91mESC[1mError: ESC[0m<message>`` on
104
+ stderr (whether or not it is a terminal) and sets exit 1, even when the
105
+ session goes on after it and finishes its turn; a tool's error
106
+ is the same line right after the tool's ``ESC[0m✗ ESC[0m<title> failed``
107
+ line and fails nothing (read from the 2.0.26 build). An exit 1 of an
108
+ ``openai/*`` model whose run-level errors are all on a short list, each
109
+ compared as a whole message (:data:`_OPENCODE_OPENAI_TRANSIENT_ERRORS`),
110
+ is reported as a :attr:`AgentExecutionResult.transient_failure`:
111
+ ``WebSocket closed with code 1000`` (the socket to the model closed
112
+ under a running turn, #164), ``WebSocket closed with code 1008`` and
113
+ OpenAI's generic ``server_error: An error occurred while processing your
114
+ request. You can retry your request, ...`` (#226). OpenAI documents no
115
+ close code for its Responses WebSocket (its 60-minute connection limit
116
+ is an error event, not a code), and 1008 is RFC 6455's generic policy
117
+ code; 1008 is listed because OpenCode's own retry rule (read from the
118
+ 2.0.32 build) retries a socket closed with any code but 1009 (message
119
+ too big) as it retries a ``server_error``, and each of the two runs of
120
+ #226 reconnected after one and finished its turn. The ``server_error``
121
+ message is compared whole, not by its ``server_error: `` prefix: another
122
+ text under that code (one naming a request ID, say) is not the one that
123
+ says to retry, and stays a plain failed exit. A transient exit stays a
124
+ failed exit too (a CONTROL_RESULT it printed is not read), and whether
125
+ to relaunch is the engine's policy. Text anywhere else in the trace (a
126
+ diff, a file, a command's output) is not an error line, and a truncated
127
+ ``stderr`` or a timeout is never classified. Not visible to this check:
128
+ an error the CLI does not print (it prints one execution failure only
129
+ when no step failed before it) and an exit 1 without any error line (a
130
+ cancelled permission prompt, below); both are failed exits all the same.
131
+ * bash/edit tools run without ``--auto`` under the default agent;
132
+ ``--auto`` is opt-in via ``options.auto_approve: true``.
133
+ * a request of the agent's is refused, never put to a person (#258; run
134
+ on 2.1.4 against a loopback stand-in of the Messages API, so no model
135
+ was called, and read from its build). Without ``--auto``, a permission
136
+ a rule asks for (with the empty configuration below, a read of ``.env``
137
+ asked; a read outside the worktree did not) prints
138
+ ``ESC[93mESC[1m! ESC[0mpermission requested: <permission> (<patterns>);
139
+ auto-rejecting`` and is rejected. The ``question`` tool is offered, and
140
+ a question is cancelled: its failed line, then the error line
141
+ :data:`_OPENCODE_QUESTION_CANCELLED`, with no question text. Either way
142
+ the model is told to go on without it, and the run goes on and exits 0.
143
+ 2.0.26 was read, not run, to exit 1 with no error line after a cancelled
144
+ permission prompt; whether it printed the line above is not recorded.
145
+ So an exit 1 with no run-level error after such a line is that refusal,
146
+ reported as ADR 0007 D7.6's failure; any other failed run only has the
147
+ refusals added to its text (:func:`_name_refused`).
148
+ * ``opencode --version`` prints ``opencode v2.0.23``; 1.x printed the
149
+ bare version and rejects this argv, so ``autoforge doctor`` requires
150
+ :data:`OPENCODE_MIN_VERSION`.
151
+ * no flag disables the MCP servers or plugins of the user's or the
152
+ project's ``opencode.json`` (2.0.26 has neither ``--pure`` nor an MCP
153
+ switch), so every launch sets :func:`opencode_isolation_env` instead
154
+ (#165): an empty global configuration directory of its own, no project
155
+ configuration, no explicit file and no inline document. Read from the
156
+ 2.0.26 build and checked against it with ``opencode api --standalone
157
+ GET /api/config`` (the configuration a launch resolves): without these
158
+ variables the global document and ``plugin/`` directory, the project's,
159
+ and an ``opencode.json`` two directories above the worktree were all
160
+ loaded; with them, only the empty directory, the operator's
161
+ ``OPENCODE_CONFIG``, ``OPENCODE_CONFIG_CONTENT`` and
162
+ ``OPENCODE_CONFIG_PROJECT_DISABLE=0`` notwithstanding. The settings in
163
+ those files (custom providers, permissions, agents, instructions) are
164
+ therefore not read either; the model credentials are.
165
+
166
+ Pi (``pi 1.0.x``, ADR 0003; read from the v1.0.0 source and docs, not yet
167
+ run against an installed Pi)::
168
+
169
+ pi --mode rpc --no-session --model <pi-provider>/<model-id> --thinking <effort>
170
+
171
+ * the prompt never travels in argv: it is one JSON ``prompt`` record on
172
+ stdin. The child runs under the duplex handle (executor_duplex.py) and
173
+ the conversation (``get_state``, ``get_available_models``, ``prompt``,
174
+ events until ``agent_settled``, ``get_last_assistant_text``) is the
175
+ reducer in pi_rpc.py; ``stdout`` of the result is the final assistant
176
+ text, never a protocol record, and an in-protocol failure is reported
177
+ in :attr:`AgentExecutionResult.provider_failure`.
178
+ * ``--thinking`` takes ``off|minimal|low|medium|high|xhigh|max``; the model
179
+ is ``provider/id`` with no ``:<thinking>`` suffix (ADR 0003 §2.2, §2.3).
180
+ * no ``extra_args``: every flag that matters is the adapter's or #132's
181
+ policy's, and a pass-through could undo it (``--approve``,
182
+ ``--api-key``, ``--session-id``, ``--system-prompt``, ``--mode``, ...).
183
+ * ``pi --version`` prints the bare version; ``pi auth check --model <m>
184
+ --json --no-refresh`` reports, read-only, whether Pi holds a credential
185
+ for the model's provider (``autoforge doctor``).
186
+ * nothing asks (#258; read from the 1.3.0 build and docs): Pi has no
187
+ permission rule and no question tool, and under ``--no-extensions`` and
188
+ ``--no-approve`` no dialog opens. An extension dialog would be
189
+ cancelled and fail the run, naming its method (pi_rpc.py).
190
+
191
+ The CLIs are launched with cwd = the directory the engine chose (a
192
+ per-issue worktree for a REMOTE run, the contract's repository root for a
193
+ LOCAL one), stdin = /dev/null or the adapter's payload
194
+ (:meth:`AgentProvider.stdin_payload`), an idle limit and an optional
195
+ wall-clock ceiling (:class:`AgentRequest`; #193), and an allow-listed
196
+ environment (see executor.py): the engine passes the configured allow-list
197
+ in :attr:`AgentRequest.env_allowlist` and each adapter adds the variables
198
+ its own CLI reads (:attr:`AgentProvider.environment_names`), so the key of
199
+ one provider is never handed to another. Neither list may name a GitHub or
200
+ git credential route (:func:`autoforge.child_env.refuse_forbidden_env`),
201
+ and the variables the controller sets for the launch
202
+ (:attr:`AgentRequest.env`, #165) reach every process the adapter starts,
203
+ with the adapter's own (:meth:`AgentProvider.integration_isolation`) over
204
+ them where its CLI has no flag to keep an integration, or a shell startup
205
+ file's functions and aliases, from loading. An integration or a setting
206
+ neither can keep off refuses the launch before it starts
207
+ (:meth:`AgentProvider.integration_preflight`).
208
+
209
+ The SDK providers ``claude-sdk`` and ``opencode-sdk`` (ADR 0007) have their
210
+ static surface here: names, option keys and validation, the launch
211
+ allow-list and the sandbox declaration. Their policy (tools, permissions,
212
+ setting sources, configuration and session roots, authentication, the
213
+ environment) is :mod:`autoforge.sdk_policy`'s (#149, docs/sdk-policy.md);
214
+ their helpers and transports are #151's and #152's, and until those land
215
+ every launch of one is refused before anything starts.
216
+
217
+ Every adapter runs a :class:`~autoforge.loop_detect.LoopMonitor` under
218
+ :attr:`AgentRequest.loop_detection` (#194): Claude's stream and Pi's events
219
+ report each completed tool call as an action, and a one-shot CLI's output
220
+ (OpenCode's stderr trace, Claude's ``text`` output) is digested line by
221
+ line as it arrives. A verdict in ``kill`` mode ends the invocation through
222
+ the same group kill as a timeout, and the result is a timeout whose limit is
223
+ ``loop``; a warning is a ``loop_suspected`` progress event. The monitor's
224
+ calibration figures join ``provider_summary`` in every mode.
225
+ """
226
+
227
+ from __future__ import annotations
228
+
229
+ import dataclasses
230
+ import json
231
+ import os
232
+ import pwd
233
+ import re
234
+ import shutil
235
+ import sqlite3
236
+ import tempfile
237
+ import threading
238
+ import time
239
+ from collections.abc import Callable, Iterator, Mapping
240
+ from contextlib import closing, contextmanager
241
+ from dataclasses import dataclass, field
242
+ from pathlib import Path
243
+
244
+ from .child_env import child_shell, refuse_forbidden_env, undoes_isolation
245
+ from .claude_stream import ClaudeStream
246
+ from .config import LoopDetectionConfig, ProfileConfig
247
+ from .errors import (
248
+ ChildStdinClosedError,
249
+ ConfigurationError,
250
+ ExecutionError,
251
+ ExecutionTimeoutError,
252
+ )
253
+ from .executor import (
254
+ DEFAULT_MAX_OUTPUT_BYTES,
255
+ MAX_DEADLINE_SECONDS,
256
+ ExecutionRequest,
257
+ ExecutionResult,
258
+ describe_leftovers,
259
+ execute,
260
+ select_environment,
261
+ )
262
+ from .executor_duplex import (
263
+ DuplexChild,
264
+ DuplexRequest,
265
+ Eof,
266
+ Fragment,
267
+ Overflow,
268
+ Oversize,
269
+ Record,
270
+ Timeout,
271
+ start_duplex,
272
+ )
273
+ from .interaction import (
274
+ NO_CAPABILITIES,
275
+ Capabilities,
276
+ InteractionBroker,
277
+ InteractionOutcome,
278
+ Lifecycle,
279
+ RequestKind,
280
+ )
281
+ from .loop_detect import LIMIT_LOOP, LoopMonitor, LoopReport
282
+ from .pi_rpc import PiConversation, encodable
283
+ from .progress import ProgressEvent, ProgressKind, ProgressSink, clean, guarded
284
+ from .redaction import redact
285
+ from .sandbox import LaunchDescription, SandboxFacts
286
+ from .sandbox_policy import (
287
+ AuthItem,
288
+ AuthMode,
289
+ ProviderRequirements,
290
+ SandboxPlan,
291
+ declared_writable,
292
+ executable_requirements,
293
+ )
294
+ from .sdk_policy import (
295
+ CLAUDE_SDK,
296
+ CLAUDE_SDK_CREDENTIAL,
297
+ CLAUDE_SDK_OPTION_KEYS,
298
+ CLAUDE_SDK_PASSTHROUGH,
299
+ OPENCODE_SDK,
300
+ OPENCODE_SDK_OPTION_KEYS,
301
+ ClaudeSdkPolicy,
302
+ InteractionSettings,
303
+ OpenCodeSdkPolicy,
304
+ claude_sdk_policy,
305
+ interaction_settings,
306
+ opencode_model_problem,
307
+ opencode_sdk_policy,
308
+ sdk_launch_allowlist,
309
+ )
310
+
311
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
312
+
313
+
314
+ @dataclass
315
+ class AgentRequest:
316
+ phase: str
317
+ prompt: str
318
+ cwd: str
319
+ profile: ProfileConfig
320
+ # The limits the invocation runs under (#193; the engine resolves them
321
+ # with ``AutoForgeConfig.agent_limits``): the CLI is killed once it has
322
+ # written nothing on stdout or stderr for ``idle_timeout_seconds``
323
+ # (``None``: no idle limit, for a CLI that reports no activity while it
324
+ # works), or once it has run ``max_runtime_seconds`` (``None``: only the
325
+ # executor's backstop, ``MAX_DEADLINE_SECONDS``).
326
+ idle_timeout_seconds: int | None
327
+ max_runtime_seconds: int | None
328
+ attempt: int = 1
329
+ correction: bool = False
330
+ # The controller's environment allow-list (``execution.env_allowlist``
331
+ # plus ``env_allowlist_extra``); the provider adds its own names. ``None``
332
+ # inherits the whole environment and is never what the engine sends.
333
+ env_allowlist: tuple[str, ...] | None = None
334
+ # What the controller sets over the allow-listed environment for this
335
+ # launch (#165, :func:`autoforge.child_env.isolated_overrides`): an empty
336
+ # gh configuration, a placeholder token, the git configuration that
337
+ # disables every credential route, the author identity. Every adapter
338
+ # passes it to every process it starts for the launch, a preflight
339
+ # included; ``None`` sets nothing.
340
+ env: dict[str, str] | None = None
341
+ # Where the adapter reports live progress (#192): provider-neutral,
342
+ # already bounded and redacted events, never a raw provider record.
343
+ # ``None`` reports nothing. A sink that raises is dropped for the rest of
344
+ # the invocation (:func:`autoforge.progress.guarded`); progress never
345
+ # changes an outcome.
346
+ progress: ProgressSink | None = None
347
+ # How the adapter watches the invocation for a loop (#194): the engine
348
+ # passes ``execution.loop_detection``, and the request keeps it with the
349
+ # mode its own provider and phase run under (#199).
350
+ loop_detection: LoopDetectionConfig = field(default_factory=LoopDetectionConfig)
351
+ # The outer sandbox every process of the launch runs in (ADR 0005, #168):
352
+ # the engine's facts, to which the adapter adds its CLI's declaration
353
+ # (:meth:`AgentProvider.sandbox_requirements`). ``None`` launches as
354
+ # before the sandbox existed (``sandbox.mode: disabled``).
355
+ sandbox: SandboxPlan | None = None
356
+ # Where the adapter takes the agent's requests for a decision (ADR 0007
357
+ # D5.2, #134): the launch's broker, created by the engine for a provider
358
+ # whose ``interaction_settings`` names a policy, as it hands over
359
+ # ``progress``. The adapter mints each provider ask through it and does
360
+ # what the directive says; it never sees the policy. ``None`` (every
361
+ # provider through which nothing can ask) keeps today's behaviour.
362
+ interaction: InteractionBroker | None = None
363
+
364
+ def __post_init__(self) -> None:
365
+ self.loop_detection = self.loop_detection.for_invocation(self.profile.provider, self.phase)
366
+
367
+
368
+ @dataclass
369
+ class AgentExecutionResult:
370
+ command: list[str]
371
+ exit_code: int
372
+ stdout: str
373
+ stderr: str
374
+ started_at: str
375
+ finished_at: str
376
+ timed_out: bool = False
377
+ provider: str = ""
378
+ model: str = ""
379
+ effort: str = ""
380
+ # Capture facts from the executor (see :class:`ExecutionResult`): the
381
+ # engine logs the whole marked ``stdout`` but parses only ``stdout_tail``.
382
+ stdout_truncated: bool = False
383
+ stderr_truncated: bool = False
384
+ stdout_tail_offset: int = 0
385
+ # What the invocation left behind (see :class:`ExecutionResult`): the
386
+ # engine records these in the run log; none of them changes whether the
387
+ # child's own result is read.
388
+ descendants_killed: bool = False
389
+ group_survived_kill: bool = False
390
+ capture_abandoned: bool = False
391
+ orphans_killed: bool = False
392
+ orphan_survived_kill: bool = False
393
+ orphans_unchecked: bool = False
394
+ # A run that failed inside the provider's protocol although the process
395
+ # may have exited 0 (Pi shuts down in order whether its run failed or
396
+ # not): a short, bounded, already-redacted reason. The engine treats it
397
+ # like a non-zero exit (ADR 0003 §2.6). A one-shot CLI adapter sets it
398
+ # only when the CLI exited 0 without reading the whole prompt from stdin,
399
+ # or when a run that failed anyway had a request of the agent's refused
400
+ # by the CLI, which it names (#258, :func:`_name_refused`).
401
+ provider_failure: str | None = None
402
+ # A failed run the adapter recognised as a known transient failure of
403
+ # the provider (#164): a short, fixed reason naming it, never text taken
404
+ # from the output. It is a fact about this failure, not a decision: it
405
+ # never replaces the exit status or ``provider_failure``, and whether to
406
+ # relaunch is the engine's policy, which reads no provider name or error
407
+ # text of its own.
408
+ transient_failure: str | None = None
409
+ # A small flat mapping of scalars, bounded and redacted by the adapter,
410
+ # that the engine writes to ``execution.json`` under this key without
411
+ # interpreting it. Never raw protocol records or message contents.
412
+ provider_summary: dict[str, str | int | bool] = field(default_factory=dict)
413
+ # Which limit killed the CLI when ``timed_out`` (``"idle"``,
414
+ # ``"max_runtime"``, or ``"loop"`` when the loop detector did, #194), and
415
+ # when it last wrote anything (UTC, ISO 8601; ``None`` when it wrote
416
+ # nothing). See :class:`ExecutionResult`.
417
+ timeout_limit: str = ""
418
+ last_activity_at: str | None = None
419
+ # What the loop detector found (#194): the finding it killed for, or the
420
+ # strongest it warned about; ``None`` when neither. Names and counts only.
421
+ loop: LoopReport | None = None
422
+ # What the outer sandbox recorded for a sandboxed launch (ADR 0005
423
+ # D10.4), for the run log; ``None`` without one. Never read for a decision.
424
+ sandbox: SandboxFacts | None = None
425
+ # How an invocation through which the agent could ask ended (ADR 0007
426
+ # D10.5, #134): ``completed``, ``failed`` (the adapter also reports
427
+ # ``provider_failure``, and the engine raises InteractionRequiredError,
428
+ # never correcting or relaunching), ``interrupted``, or ``paused`` with
429
+ # the pending request (#126 persists it). ``None`` for a launch without
430
+ # a broker, with one exception: a CLI adapter, which has no broker and
431
+ # declares no capability, reports ``failed`` when its CLI's output shows
432
+ # that a request the CLI refused is what failed the run (#258, D7.6).
433
+ interaction: InteractionOutcome | None = None
434
+
435
+ @property
436
+ def stdout_tail(self) -> str:
437
+ """The part of stdout captured contiguously up to EOF."""
438
+ return self.stdout[self.stdout_tail_offset :]
439
+
440
+ @property
441
+ def leftovers(self) -> str:
442
+ """What the invocation left behind, for a log line; empty when nothing."""
443
+ return describe_leftovers(
444
+ descendants_killed=self.descendants_killed,
445
+ group_survived_kill=self.group_survived_kill,
446
+ capture_abandoned=self.capture_abandoned,
447
+ orphans_killed=self.orphans_killed,
448
+ orphan_survived_kill=self.orphan_survived_kill,
449
+ orphans_unchecked=self.orphans_unchecked,
450
+ )
451
+
452
+ @property
453
+ def may_have_survivor(self) -> bool:
454
+ """Whether a process the invocation started may still be running: a
455
+ member of its group outlived SIGKILL, a writer beyond the kill's reach
456
+ still held its pipes, or a re-parented process outlived its kill. Not
457
+ ``orphans_unchecked``, which is a fact of the platform (no subreaper)
458
+ and true of every invocation there, not evidence of a survivor."""
459
+ return self.group_survived_kill or self.capture_abandoned or self.orphan_survived_kill
460
+
461
+ @property
462
+ def ok(self) -> bool:
463
+ """Whether the invocation ran to a usable end.
464
+
465
+ Narrower than :attr:`ExecutionResult.ok` on purpose: for plumbing
466
+ (``gh --json``, ``git``) the whole output is the reply, so either
467
+ stream past the bound spoils it, whereas an agent's reply is the
468
+ CONTROL_RESULT on stdout and its stderr is diagnostics (an OpenCode
469
+ tool trace), which the engine logs but never parses; a stderr past
470
+ the bound is recorded, not a failure. The engine reads the underlying
471
+ facts (``timed_out``, ``exit_code``, ``stdout_tail``) rather than
472
+ this property.
473
+ """
474
+ return not self.timed_out and self.exit_code == 0 and not self.stdout_truncated
475
+
476
+ @classmethod
477
+ def from_execution(cls, res: ExecutionResult, profile: ProfileConfig) -> AgentExecutionResult:
478
+ return cls(
479
+ command=list(res.command),
480
+ exit_code=res.exit_code,
481
+ stdout=res.stdout,
482
+ stderr=res.stderr,
483
+ started_at=res.started_at,
484
+ finished_at=res.finished_at,
485
+ timed_out=res.timed_out,
486
+ stdout_truncated=res.stdout_truncated,
487
+ stderr_truncated=res.stderr_truncated,
488
+ stdout_tail_offset=res.stdout_tail_offset,
489
+ descendants_killed=res.descendants_killed,
490
+ group_survived_kill=res.group_survived_kill,
491
+ capture_abandoned=res.capture_abandoned,
492
+ orphans_killed=res.orphans_killed,
493
+ orphan_survived_kill=res.orphan_survived_kill,
494
+ orphans_unchecked=res.orphans_unchecked,
495
+ provider=profile.provider,
496
+ model=profile.model,
497
+ effort=profile.effort,
498
+ timeout_limit=res.timeout_limit,
499
+ last_activity_at=res.last_activity_at,
500
+ sandbox=res.sandbox,
501
+ )
502
+
503
+
504
+ def _activity_hook(progress: ProgressSink | None) -> Callable[[str], None] | None:
505
+ """An executor output hook that reports each chunk as bare activity.
506
+
507
+ For a one-shot CLI whose output is not a protocol the adapter reads
508
+ while it runs (OpenCode's ``--format default``, Claude's ``text``): a
509
+ chunk on either stream is a sign of life and nothing more. Its bytes
510
+ are never looked at, so nothing the CLI printed reaches the progress
511
+ output.
512
+ """
513
+ emit = guarded(progress)
514
+ if emit is None:
515
+ return None
516
+
517
+ def on_output(stream: str) -> None:
518
+ emit(ProgressEvent(ProgressKind.ACTIVITY))
519
+
520
+ return on_output
521
+
522
+
523
+ def _loop_monitor(req: AgentRequest, emit: ProgressSink | None) -> LoopMonitor:
524
+ """The invocation's loop detector (#194), warning through ``emit``."""
525
+
526
+ def warn(text: str) -> None:
527
+ if emit is not None:
528
+ emit(ProgressEvent(ProgressKind.LOOP_SUSPECTED, detail=text))
529
+
530
+ return LoopMonitor(req.loop_detection, time.monotonic(), warn)
531
+
532
+
533
+ class _ClockedLoop:
534
+ """A :class:`LoopMonitor` as a clock-free reducer reports to it: each
535
+ event is timed as it is reduced, so the reducer reads no clock."""
536
+
537
+ def __init__(self, monitor: LoopMonitor) -> None:
538
+ self._monitor = monitor
539
+
540
+ def action(self, name: str, fingerprint: bytes) -> None:
541
+ self._monitor.action(name, fingerprint, time.monotonic())
542
+
543
+ def retry(self) -> None:
544
+ self._monitor.retry(time.monotonic())
545
+
546
+ def turn(self) -> None:
547
+ self._monitor.turn(time.monotonic())
548
+
549
+
550
+ def _finish_loop(
551
+ result: AgentExecutionResult, monitor: LoopMonitor, *, killed: bool
552
+ ) -> AgentExecutionResult:
553
+ """Record the loop detector's outcome on ``result`` (#194).
554
+
555
+ ``killed``: the invocation was ended on the verdict. It is then a
556
+ timeout whose limit is ``loop``, which the engine and the run log treat
557
+ exactly as the other limits: whatever the protocol said is not read. The
558
+ calibration figures join ``provider_summary`` whatever the mode.
559
+ """
560
+ result.provider_summary.update(monitor.calibration())
561
+ result.loop = monitor.report(killed=killed)
562
+ if killed:
563
+ result.timed_out = True
564
+ result.timeout_limit = LIMIT_LOOP
565
+ result.provider_failure = None
566
+ return result
567
+
568
+
569
+ # Where an operator reads what to do about a request the CLI refused (#258).
570
+ REFUSED_REQUEST_GUIDE = "docs/usage.md, 'An agent asked for permission or input'"
571
+ MAX_REFUSED_SUBJECT_CHARS = 160
572
+
573
+
574
+ def _requests(refused: list[tuple[RequestKind, str]], count: int) -> str:
575
+ """What the agent asked for, for a failure: the first request, and how many more."""
576
+ kind, subject = refused[0]
577
+ if kind is RequestKind.PERMISSION:
578
+ text = f"permission ({subject})" if subject else "permission"
579
+ else:
580
+ text = "an answer to a question"
581
+ if count > 1:
582
+ text += f" and {count - 1} more request{'s' if count > 2 else ''}"
583
+ return text
584
+
585
+
586
+ def _name_refused(
587
+ result: AgentExecutionResult,
588
+ refused: list[tuple[RequestKind, str]],
589
+ count: int,
590
+ name: str,
591
+ cli: str,
592
+ *,
593
+ ended_by_it: str = "",
594
+ ) -> AgentExecutionResult:
595
+ """Name on ``result`` the ``count`` requests of the agent's the CLI refused (#258).
596
+
597
+ ``refused`` is the first of them, each its kind and its cleaned subject;
598
+ ``name`` is the adapter's and ``cli`` the CLI's, as a reader knows it.
599
+ The run log always gets the count and the first one (``provider_summary``).
600
+ A run that ends usable is left as it is: the CLI told the agent it was
601
+ refused, and the agent went on without it. A timeout keeps its own text.
602
+
603
+ ``ended_by_it``, the adapter's finding that the refusal is all that
604
+ failed the run (with what to do about it), makes the failure the named
605
+ one of ADR 0007 D7.6: ``provider_failure`` says what the agent asked for
606
+ and ``interaction`` is ``failed``, so the engine raises
607
+ InteractionRequiredError, never corrected and never relaunched: a
608
+ relaunch would ask again. The adapter declares no capability for it
609
+ (D5.3). Any other failure keeps its own reason, its exit status and a
610
+ transient finding, and only has the refusal added to its text: the
611
+ refusal may not be why it failed.
612
+ """
613
+ if not count:
614
+ return result
615
+ kind, subject = refused[0]
616
+ result.provider_summary["refused_requests"] = count
617
+ result.provider_summary["refused_request"] = f"{kind} ({subject})" if subject else str(kind)
618
+ if result.timed_out:
619
+ return result
620
+ asked = _requests(refused, count)
621
+ if ended_by_it:
622
+ result.provider_failure = (
623
+ f"{name}: the agent asked for {asked}, which an unattended run "
624
+ f"cannot give; {ended_by_it}"
625
+ )
626
+ result.transient_failure = None
627
+ result.interaction = InteractionOutcome(Lifecycle.FAILED)
628
+ elif result.provider_failure or result.exit_code != 0:
629
+ failure = result.provider_failure or f"{name}: exited {result.exit_code}"
630
+ result.provider_failure = (
631
+ f"{failure}; during the run the agent asked for {asked}, which {cli} refused "
632
+ f"({REFUSED_REQUEST_GUIDE})"
633
+ )
634
+ return result
635
+
636
+
637
+ def _truthy(value: str | None, default: bool) -> bool:
638
+ if value is None or value == "":
639
+ return default
640
+ return value.strip().lower() in ("1", "true", "yes", "on")
641
+
642
+
643
+ class AgentProvider:
644
+ """Base adapter. Subclasses implement ``build_command_for``, and
645
+ ``stdin_payload`` when the prompt does not travel in argv."""
646
+
647
+ name = "base"
648
+ # The environment variables this provider's CLI reads for its own
649
+ # authentication and configuration, added to the request's allow-list.
650
+ # Names, never values: the executor selects them from the environment.
651
+ environment_names: tuple[str, ...] = ()
652
+ # The profile ``options`` keys this adapter reads. Any other key is a
653
+ # configuration error naming the profile and this set, so a misspelled
654
+ # knob cannot be a silent no-op. ``None`` leaves the keys unchecked; only
655
+ # the scripted test provider, which launches no real CLI, uses it.
656
+ option_keys: tuple[str, ...] | None = None
657
+ # The variables :meth:`integration_isolation` sets for this CLI, names
658
+ # only (the run log records them; the values vary by launch).
659
+ isolation_names: tuple[str, ...] = ()
660
+ # The variables this CLI reads that name a path (ADR 0005 D8.2): a
661
+ # sandboxed launch keeps one only when what it names is visible inside.
662
+ path_names: tuple[str, ...] = ()
663
+
664
+ def __init__(self, runner: Runner | None = None) -> None:
665
+ self._runner: Runner = runner or execute
666
+
667
+ # -- to override -----------------------------------------------------
668
+ def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
669
+ raise NotImplementedError
670
+
671
+ def stdin_payload(self, req: AgentRequest) -> bytes | None:
672
+ """What :meth:`execute` writes to the CLI's stdin; ``None`` is ``/dev/null``."""
673
+ return None
674
+
675
+ @contextmanager
676
+ def integration_isolation(self) -> Iterator[dict[str, str]]:
677
+ """The variables this CLI needs, for one launch, so that no MCP server,
678
+ plugin or hook of the operator's or the repository's configuration
679
+ loads and no function or alias of a shell startup file runs in its
680
+ shell tool (#165): one may hold a GitHub token of its own.
681
+
682
+ Set over the controller's (:attr:`AgentRequest.env`) by
683
+ :meth:`execute`, whatever the allow-list passed. An adapter whose CLI
684
+ has a flag for it says so in its argv instead and sets nothing here.
685
+ """
686
+ yield {}
687
+
688
+ def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
689
+ """Refuse a launch whose CLI would still load an integration that
690
+ neither its flags nor :meth:`integration_isolation` keep off (#165).
691
+
692
+ ``env_allowlist`` is the configured allow-list (this provider adds
693
+ its own names, as for the launch). Raises :class:`ConfigurationError`
694
+ before anything starts: the engine calls this with the credential
695
+ preflight, before a launch is charged, and :meth:`execute` calls it
696
+ again before it launches. The base adapter has nothing to refuse.
697
+ """
698
+
699
+ def launch_environment(self, env_allowlist: tuple[str, ...] | None) -> dict[str, str]:
700
+ """The variables of the controller's environment a launch with
701
+ ``env_allowlist`` inherits, before any the controller sets."""
702
+ if env_allowlist is None:
703
+ return dict(os.environ)
704
+ return select_environment(self.launch_allowlist(env_allowlist))
705
+
706
+ def reports_activity(self, profile: ProfileConfig) -> bool:
707
+ """Whether the CLI, launched for ``profile``, writes output while it works.
708
+
709
+ A fact about the CLI, not a policy: the controller decides what to do
710
+ with a CLI that is silent until it exits (it cannot run under an idle
711
+ limit, see ``AutoForgeConfig.agent_limits``).
712
+ """
713
+ return True
714
+
715
+ def interaction_settings(self, profile: ProfileConfig) -> InteractionSettings | None:
716
+ """``profile``'s interaction policy and limits (ADR 0007 D7.1, D7.3), or
717
+ ``None`` for a provider through which nothing can ask. Validated:
718
+ :class:`ConfigurationError` for a value the policy refuses."""
719
+ return None
720
+
721
+ def interaction_capabilities(self, profile: ProfileConfig) -> Capabilities:
722
+ """What this provider can carry of a decision for ``profile`` (ADR 0007
723
+ D5.1, D5.3), for the launch's broker.
724
+
725
+ A capability is declared true only once every ADR 0007 §3 gate of its
726
+ row passed on the provider's pinned pair. None has yet, so every
727
+ adapter declares :data:`~autoforge.interaction.NO_CAPABILITIES`: an
728
+ ask fails the invocation under every policy.
729
+ """
730
+ return NO_CAPABILITIES
731
+
732
+ def validate_profile(self, profile: ProfileConfig) -> None:
733
+ """Raise ConfigurationError for values this CLI cannot accept.
734
+
735
+ An override calls this first: it refuses an environment name that
736
+ would hand the CLI a GitHub or git credential (#165) and checks the
737
+ ``options`` keys.
738
+ """
739
+ refuse_forbidden_env(self.environment_names, f"provider {self.name!r}")
740
+ if self.option_keys is None:
741
+ return
742
+ unknown = sorted(set(profile.options) - set(self.option_keys))
743
+ if unknown:
744
+ raise ConfigurationError(
745
+ f"profile {profile.name!r}: unknown {self.name} option(s) "
746
+ f"{', '.join(unknown)} (accepted: {', '.join(self.option_keys) or 'none'})"
747
+ )
748
+
749
+ def sandbox_requirements(
750
+ self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
751
+ ) -> ProviderRequirements:
752
+ """What a sandboxed launch of ``command`` needs from the host, as data (ADR 0005 D13.1).
753
+
754
+ ``env`` is the launch's environment (the allow-listed variables, the
755
+ controller's overrides and :meth:`integration_isolation`'s) and
756
+ ``home`` the operator's home. The base declaration is the executable,
757
+ resolved on the launch's ``PATH`` as the spawn would resolve it, its
758
+ install root and its interpreter's, an absolute ``#!`` path as
759
+ written (a spelled path, #225), and :attr:`path_names`; an
760
+ adapter adds its configuration, credential and state items. Names
761
+ and paths only: no file is read but an executable's first line.
762
+ """
763
+ executable, roots, path_dirs, spelled = executable_requirements(
764
+ command[0], home=home, search_path=env.get("PATH")
765
+ )
766
+ return ProviderRequirements(
767
+ executable=executable,
768
+ install_roots=roots,
769
+ path_dirs=path_dirs,
770
+ spelled_paths=spelled,
771
+ path_names=self.path_names,
772
+ )
773
+
774
+ def written_directories(
775
+ self, profile: ProfileConfig, env_allowlist: tuple[str, ...] | None, home: str
776
+ ) -> tuple[tuple[str, str], ...]:
777
+ """The host directories a sandboxed launch of ``profile`` writes that outlive it (#280).
778
+
779
+ Each directory :meth:`sandbox_requirements` makes writable, with what
780
+ it is (:func:`~autoforge.sandbox_policy.declared_writable`), declared
781
+ over the environment a launch with ``env_allowlist`` inherits from
782
+ this process. The names :meth:`integration_isolation` sets are left
783
+ out of it: every launch sets them to a directory or file made for it
784
+ and removed afterwards, which nothing the operator keeps is in. What
785
+ a ``--config`` file may not lie in under an enforced mode, since an
786
+ agent could rewrite it there
787
+ (:func:`autoforge.engine.config_file_placement_problem`). Names and
788
+ paths only: nothing is created, and no file is read but an
789
+ executable's first line.
790
+ """
791
+ isolated = set(self.isolation_names)
792
+ env = {
793
+ name: value
794
+ for name, value in self.launch_environment(env_allowlist).items()
795
+ if name not in isolated
796
+ }
797
+ needs = self.sandbox_requirements(profile, [profile.command or self.name], env, home)
798
+ return declared_writable(needs)
799
+
800
+ # -- shared behaviour -------------------------------------------------
801
+ def build_command(self, req: AgentRequest) -> list[str]:
802
+ return self.build_command_for(req.profile, req.prompt)
803
+
804
+ def sandboxed(
805
+ self, req: AgentRequest, command: list[str], env: Mapping[str, str] | None
806
+ ) -> tuple[list[str], LaunchDescription | None]:
807
+ """``command`` as it runs inside the request's sandbox, and its description.
808
+
809
+ Unchanged, and ``None``, without :attr:`AgentRequest.sandbox`. Inside,
810
+ the executable is named by its resolved host path, which the
811
+ description shows read-only (D13.1). ``env`` is what the adapter sets
812
+ over the allow-listed environment for this process. A launch the
813
+ policy refuses raises
814
+ :class:`~autoforge.errors.SandboxLaunchRefusedError` before anything
815
+ is spawned.
816
+ """
817
+ if req.sandbox is None:
818
+ return command, None
819
+ launch_env = {**self.launch_environment(req.env_allowlist), **(env or {})}
820
+ needs = self.sandbox_requirements(req.profile, command, launch_env, req.sandbox.facts.home)
821
+ description = req.sandbox.describe(needs, launch_env)
822
+ if needs.executable:
823
+ command = [needs.executable, *command[1:]]
824
+ return command, description
825
+
826
+ def launch_allowlist(self, base: tuple[str, ...]) -> tuple[str, ...]:
827
+ """``base`` (the configured allow-list) plus this provider's own names.
828
+
829
+ Neither may name a GitHub or git credential route (#165): config load
830
+ and :meth:`validate_profile` refuse one first, and this refuses it
831
+ again for an allow-list that did not come through either.
832
+ """
833
+ names = tuple(dict.fromkeys([*base, *self.environment_names]))
834
+ refuse_forbidden_env(names, f"the {self.name} launch allow-list")
835
+ return names
836
+
837
+ def environment_allowlist(self, req: AgentRequest) -> tuple[str, ...] | None:
838
+ """The allow-list the CLI is launched with: the request's plus this provider's."""
839
+ if req.env_allowlist is None:
840
+ return None
841
+ return self.launch_allowlist(req.env_allowlist)
842
+
843
+ def execute(self, req: AgentRequest) -> AgentExecutionResult:
844
+ command = self.build_command(req)
845
+ monitor = _loop_monitor(req, guarded(req.progress))
846
+ stop = threading.Event()
847
+
848
+ def on_chunk(stream: str, chunk: bytes) -> None:
849
+ # The output is not a protocol: its lines are digested as they
850
+ # arrive and dropped (#194); the capture keeps its own copy.
851
+ if monitor.output(stream, chunk, time.monotonic()) is not None:
852
+ stop.set()
853
+
854
+ self.integration_preflight(req.env_allowlist)
855
+ with self.integration_isolation() as isolation:
856
+ env = {**(req.env or {}), **isolation} if isolation else req.env
857
+ command, description = self.sandboxed(req, command, env)
858
+ res = self._runner(
859
+ ExecutionRequest(
860
+ command=command,
861
+ cwd=req.cwd,
862
+ timeout_seconds=req.max_runtime_seconds or MAX_DEADLINE_SECONDS,
863
+ idle_timeout_seconds=req.idle_timeout_seconds,
864
+ env_allowlist=self.environment_allowlist(req),
865
+ env=env,
866
+ # Nothing an agent starts outlives its invocation (ADR
867
+ # 0002), including what it detached from its process group.
868
+ contain_orphans=True,
869
+ stdin_data=self.stdin_payload(req),
870
+ on_output=_activity_hook(req.progress),
871
+ on_chunk=on_chunk,
872
+ stop=stop,
873
+ sandbox=description,
874
+ )
875
+ )
876
+ result = AgentExecutionResult.from_execution(res, req.profile)
877
+ if res.stdin_unread and res.exit_code == 0 and not res.timed_out and not res.stopped:
878
+ # A clean exit would otherwise be read as an answer to the whole
879
+ # prompt; a timeout or a failed exit already says more.
880
+ result.provider_failure = (
881
+ f"{self.name}: the prompt was not delivered: the CLI exited without "
882
+ f"reading the last {res.stdin_unread} bytes of it"
883
+ )
884
+ return _finish_loop(result, monitor, killed=res.stopped)
885
+
886
+
887
+ CLAUDE_EFFORTS = ("low", "medium", "high", "xhigh", "max")
888
+ CLAUDE_PERMISSION_MODES = ("acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan")
889
+ # `stream-json` (the default) reports progress while the agent runs and
890
+ # carries the final text in its last `result` line; `text` prints only the
891
+ # final text, at exit, and is kept as an explicit opt-out. `json` prints one
892
+ # object at exit: no progress, so nothing over `text`, and not accepted.
893
+ CLAUDE_OUTPUT_FORMATS = ("stream-json", "text")
894
+ CLAUDE_DEFAULT_OUTPUT_FORMAT = "stream-json"
895
+ # One stream-json line is bounded like a Pi record: the `result` line must
896
+ # arrive whole, and JSON escaping can double a final text that is itself
897
+ # up to the stdout bound. A longer line (a `tool_result` quoting a large
898
+ # file) is skipped and counted, and is never buffered whole.
899
+ CLAUDE_MAX_RECORD_BYTES = 2 * DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024
900
+ CLAUDE_MAX_PENDING_BYTES = 2 * CLAUDE_MAX_RECORD_BYTES
901
+ # How long the stream driver waits for a line before it checks whether the
902
+ # CLI has exited (it also checks after every line): a descendant holding
903
+ # stdout keeps EOF away, and this is how late its exit grace (ADR 0002) can
904
+ # start.
905
+ CLAUDE_EXIT_POLL_SECONDS = 0.25
906
+ # What every launch adds so that no integration of the operator's or the
907
+ # repository's configuration runs (#165): one may hold a GitHub token of its
908
+ # own. ``--safe-mode`` keeps hooks, installed plugins, MCP servers, skills,
909
+ # custom agents and commands, and CLAUDE.md from loading, those of
910
+ # ``--settings``, ``--plugin-dir`` and ``--mcp-config`` included, and wins
911
+ # over ``CLAUDE_CODE_SAFE_MODE=0``; ``--strict-mcp-config`` with no
912
+ # ``--mcp-config`` keeps MCP servers off on its own. ``--setting-sources=user``
913
+ # reads no project or local settings (``.claude/settings.json`` and
914
+ # ``settings.local.json`` of the worktree, which the repository controls):
915
+ # 2.1.296 applies their ``env`` over the launch's under ``--safe-mode`` too,
916
+ # so a ``CLAUDE_ENV_FILE`` there would undo :data:`CLAUDE_SHELL_CLEANER`
917
+ # (seen with a loopback API). The user's settings stay, checked instead
918
+ # (:func:`claude_configuration_problems`).
919
+ CLAUDE_ISOLATION_FLAGS = ("--safe-mode", "--strict-mcp-config", "--setting-sources=user")
920
+ # The flags that would bring an integration or the repository's settings
921
+ # back, refused in ``extra_args``: MCP servers, plugins, settings (hooks
922
+ # among them), setting sources and agent definitions, and the browser and
923
+ # IDE connections.
924
+ CLAUDE_INTEGRATION_FLAGS = (
925
+ "--mcp-config",
926
+ "--plugin-dir",
927
+ "--plugin-url",
928
+ "--settings",
929
+ "--setting-sources",
930
+ "--agents",
931
+ "--chrome",
932
+ "--ide",
933
+ )
934
+
935
+
936
+ def _integration_flag(arg: str) -> str | None:
937
+ """The integration flag ``arg`` is (``--flag`` or ``--flag=value``); None if none."""
938
+ for flag in CLAUDE_INTEGRATION_FLAGS:
939
+ if arg == flag or arg.startswith(flag + "="):
940
+ return flag
941
+ return None
942
+
943
+
944
+ # The shell Claude Code's Bash tool runs a command in (#165). 2.1.296 (read
945
+ # from the build, checked against it with a loopback API) starts
946
+ # ``$CLAUDE_CODE_SHELL`` when that names a bash or a zsh, else picks one
947
+ # itself. Once per session it runs that shell as ``<shell> -c -l``, sources
948
+ # ``$HOME/.zshrc`` or ``$HOME/.bashrc`` there (by ``HOME``, whatever
949
+ # ``ZDOTDIR`` says) and writes the functions, aliases and options it then
950
+ # has to a snapshot; every command sources the snapshot first or, without
951
+ # one, runs in a login shell (``-c -l``, which reads ``~/.bash_profile``).
952
+ # A function or alias of the operator's there (``gh() { GH_TOKEN=... command
953
+ # gh "$@"; }``) would hand the agent's ordinary ``gh`` a credential, and no
954
+ # flag or variable turns the snapshot off. What 2.1.296 runs next in the
955
+ # same shell, before the command and in both cases, is the text of the file
956
+ # ``CLAUDE_ENV_FILE`` names, so every launch names a file of its own holding
957
+ # :data:`CLAUDE_SHELL_CLEANER`; it also empties ``CLAUDE_CODE_SHELL_PREFIX``,
958
+ # a program 2.1.296 would run every command through, and sets
959
+ # ``CLAUDE_CODE_SHELL`` itself, which could otherwise name a program of the
960
+ # operator's to start as the shell.
961
+ CLAUDE_SHELLS = ("bash", "zsh")
962
+ CLAUDE_ENV_FILE_NAME = "env.sh"
963
+ # Inlined by 2.1.296 after the snapshot is sourced and before the command
964
+ # (``<snapshot> && <this>\n: && ... && eval <command>``): it removes every
965
+ # function and alias, the operator's and Claude Code's own (its ``rg`` and
966
+ # ``pkill`` wrappers) alike, then checks that none is left and otherwise
967
+ # ends the shell, so the command does not run. The block is one line after
968
+ # the comment, joined to the snapshot line by ``&&``, so it is parsed before
969
+ # the snapshot defines an alias; every command word is ``\``-quoted, so an
970
+ # alias of the login files cannot expand either. In bash, assigning
971
+ # ``POSIXLY_CORRECT`` (no function shadows an assignment) puts the special
972
+ # builtin ``unset`` ahead of any function, which then removes whatever
973
+ # function shadows ``builtin`` or ``read``. zsh has no such mode: whatever
974
+ # ``builtin`` cannot remove, the count of ``$functions`` and the alias
975
+ # tables (parameters, which nothing shadows) refuses. ``${x:?}`` of an
976
+ # empty ``x``, an expansion and no command, ends a non-interactive shell.
977
+ _CLAUDE_CLEANER_LEFT = (
978
+ "__af_left=; __af_x=${__af_left:?AutoForge: a function or alias of a shell startup "
979
+ "file is still defined, so this command is not run (#165)}"
980
+ )
981
+ CLAUDE_SHELL_CLEANER = (
982
+ "# Written by AutoForge for one Claude Code launch (#165): no function or alias of a "
983
+ "shell startup file runs in a Bash tool command.\n"
984
+ "{ if [[ -n ${BASH_VERSION-} ]]; then "
985
+ "case :${SHELLOPTS-}: in (*:posix:*) __af_posix=1 ;; "
986
+ "(*) __af_posix= ; POSIXLY_CORRECT=y ;; esac; "
987
+ "\\unset -f builtin command declare read unset unalias alias; "
988
+ "[[ -n $__af_posix ]] || \\unset POSIXLY_CORRECT; "
989
+ 'while \\builtin read -r __af_f; do \\builtin unset -f -- "${__af_f##* }" || __af_rc=1; '
990
+ "done < <(\\builtin declare -F); "
991
+ "\\builtin unalias -a 2>/dev/null || __af_rc=1; "
992
+ "[[ -z $(\\builtin declare -F) && -z $(\\builtin alias) ]] || { "
993
+ + _CLAUDE_CLEANER_LEFT
994
+ + "; }; "
995
+ "\\builtin unset __af_f __af_posix __af_rc __af_x __af_left; "
996
+ "elif [[ -n ${ZSH_VERSION-} ]]; then "
997
+ "\\builtin unfunction -m '*' 2>/dev/null || __af_rc=1; "
998
+ "\\builtin unalias -a 2>/dev/null || __af_rc=1; "
999
+ "\\builtin unalias -as 2>/dev/null || __af_rc=1; "
1000
+ "(( ${+functions} && ${+aliases} && ${+galiases} && ${+saliases} "
1001
+ "&& ${#functions} + ${#aliases} + ${#galiases} + ${#saliases} == 0 )) || { "
1002
+ + _CLAUDE_CLEANER_LEFT
1003
+ + "; }; "
1004
+ "\\builtin unset __af_rc __af_x __af_left; "
1005
+ "else __af_left=; __af_x=${__af_left:?AutoForge: this command would run in a shell "
1006
+ "AutoForge cannot clear of shell startup functions and aliases (#165)}; fi; }\n"
1007
+ )
1008
+
1009
+
1010
+ def claude_shell(shell: str) -> str:
1011
+ """The shell a Claude Code launch's Bash tool runs in (``CLAUDE_CODE_SHELL``):
1012
+ ``shell`` (the launch's ``SHELL``, :func:`autoforge.child_env.child_shell`)
1013
+ when it is a bash or a zsh, else the ``bash`` or ``zsh`` on ``PATH``;
1014
+ empty when there is neither (2.1.296 then looks for one itself, and
1015
+ :data:`CLAUDE_SHELL_CLEANER` clears either)."""
1016
+ if os.path.basename(shell) in CLAUDE_SHELLS:
1017
+ return shell
1018
+ return shutil.which("bash") or shutil.which("zsh") or ""
1019
+
1020
+
1021
+ def claude_isolation_env(env_file: str, shell: str) -> dict[str, str]:
1022
+ """What a Claude Code launch sets so that its Bash tool runs no function
1023
+ or alias of a shell startup file and no program of the operator's
1024
+ around a command: ``env_file`` holds :data:`CLAUDE_SHELL_CLEANER`."""
1025
+ return {
1026
+ "CLAUDE_ENV_FILE": env_file,
1027
+ "CLAUDE_CODE_SHELL": shell,
1028
+ "CLAUDE_CODE_SHELL_PREFIX": "",
1029
+ }
1030
+
1031
+
1032
+ # Read whole to find its ``env``, and refused past this size: Claude Code's
1033
+ # global configuration grows with its project history.
1034
+ CLAUDE_MAX_CONFIG_BYTES = 32 * 1024 * 1024
1035
+
1036
+
1037
+ def claude_config_files(env: Mapping[str, str]) -> list[Path]:
1038
+ """The operator's files whose ``env`` a Claude Code launch with ``env``
1039
+ sets over its own environment, and so over every command its Bash tool
1040
+ runs, ``--safe-mode`` notwithstanding (2.1.296, read from the build and
1041
+ seen replacing the placeholder ``GH_TOKEN`` and ``CLAUDE_ENV_FILE``): the
1042
+ user settings, ``settings.json`` in the configuration directory
1043
+ (``CLAUDE_CONFIG_DIR``, else ``~/.claude``), and the global
1044
+ configuration, ``.config.json`` there when it exists, else
1045
+ ``.claude.json`` (``.claude-custom-oauth.json`` with
1046
+ ``CLAUDE_CODE_CUSTOM_OAUTH_URL``) in ``CLAUDE_CONFIG_DIR``, else in the
1047
+ home (``HOME``, else the password database's)."""
1048
+ home = env.get("HOME")
1049
+ if not home:
1050
+ try:
1051
+ home = pwd.getpwuid(os.getuid()).pw_dir
1052
+ except KeyError:
1053
+ home = "/"
1054
+ explicit = env.get("CLAUDE_CONFIG_DIR")
1055
+ config_dir = Path(explicit or Path(home, ".claude"))
1056
+ legacy = config_dir / ".config.json"
1057
+ suffix = "-custom-oauth" if env.get("CLAUDE_CODE_CUSTOM_OAUTH_URL") else ""
1058
+ global_config = legacy if legacy.exists() else Path(explicit or home, f".claude{suffix}.json")
1059
+ return [config_dir / "settings.json", global_config]
1060
+
1061
+
1062
+ def _claude_config_env_problems(path: Path) -> list[str]:
1063
+ """The names in the ``env`` of ``path`` that would undo a launch's
1064
+ isolation, or that it could not be read; names only, never a value."""
1065
+ if not path.exists():
1066
+ return []
1067
+ refused = _not_a_regular_file(path)
1068
+ if refused:
1069
+ return [refused]
1070
+ try:
1071
+ with path.open("rb") as handle:
1072
+ data = handle.read(CLAUDE_MAX_CONFIG_BYTES + 1)
1073
+ except FileNotFoundError:
1074
+ return []
1075
+ except OSError as exc:
1076
+ return [f"{path} could not be checked ({type(exc).__name__})"]
1077
+ if len(data) > CLAUDE_MAX_CONFIG_BYTES:
1078
+ return [f"{path} is larger than {CLAUDE_MAX_CONFIG_BYTES} bytes and was not checked"]
1079
+ try:
1080
+ document = json.loads(data)
1081
+ except ValueError:
1082
+ return [f"{path} could not be checked (not JSON)"]
1083
+ env = document.get("env") if isinstance(document, dict) else None
1084
+ if not isinstance(env, dict):
1085
+ return []
1086
+ isolation = {name.upper() for name in ClaudeCodeProvider.isolation_names}
1087
+ hits = sorted(
1088
+ name
1089
+ for name in env
1090
+ if isinstance(name, str) and (undoes_isolation(name) or name.upper() in isolation)
1091
+ )
1092
+ if not hits:
1093
+ return []
1094
+ return [f"{', '.join(redact(name) for name in hits)} in the env of {path}"]
1095
+
1096
+
1097
+ def claude_configuration_problems(env: Mapping[str, str]) -> list[str]:
1098
+ """What, in the configuration a Claude Code launch with ``env`` reads,
1099
+ would set a variable over the launch's isolation (empty: nothing would).
1100
+
1101
+ ``env`` is the environment the launch inherits (``HOME``,
1102
+ ``CLAUDE_CONFIG_DIR`` and ``CLAUDE_CODE_CUSTOM_OAUTH_URL`` decide where
1103
+ 2.1.296 looks, :func:`claude_config_files`). A file that exists but
1104
+ cannot be read is a problem too: a launch is refused rather than assumed
1105
+ clean. Managed (administrator) settings are not read: the operator can
1106
+ change them no more than a launch can.
1107
+ """
1108
+ problems: list[str] = []
1109
+ for path in claude_config_files(env):
1110
+ problems += _claude_config_env_problems(path)
1111
+ return problems
1112
+
1113
+
1114
+ class ClaudeCodeProvider(AgentProvider):
1115
+ name = "claude"
1116
+ # ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` and the base URL and
1117
+ # model overrides the CLI reads; ``CLAUDE_CONFIG_DIR`` and the
1118
+ # ``CLAUDE_CODE_*`` feature switches.
1119
+ environment_names = ("ANTHROPIC_*", "CLAUDE_*")
1120
+ option_keys = ("permission_mode", "output_format", "session_persistence")
1121
+ isolation_names = tuple(sorted(claude_isolation_env("", "")))
1122
+ path_names = ("CLAUDE_ENV_FILE", "CLAUDE_CODE_SHELL", "CLAUDE_CONFIG_DIR")
1123
+
1124
+ def sandbox_requirements(
1125
+ self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
1126
+ ) -> ProviderRequirements:
1127
+ """Claude Code's declaration (ADR 0005 D7.1, D13.1).
1128
+
1129
+ The launch's ``CLAUDE_ENV_FILE`` read-only. With ``CLAUDE_CONFIG_DIR``
1130
+ set to an absolute path, that directory writable where it resolves
1131
+ (``dir-rw``: the CLI rewrites its login there; the variable names it
1132
+ there inside), refused when it is or lies in ``~/.claude`` (D7.2).
1133
+ Without it, the login file ``~/.claude/.credentials.json`` read-only
1134
+ (``file-ro``; a token the CLI must refresh cannot be written back)
1135
+ and the user settings ``~/.claude/settings.json`` read-only, both
1136
+ when present, each at that path in the private home, where the CLI
1137
+ looks for it, even when the host path is a link to a file elsewhere.
1138
+ A key in ``ANTHROPIC_*`` or ``CLAUDE_CODE_OAUTH_TOKEN`` is an ``env``
1139
+ item. ``~/.claude`` and ``~/.claude.json`` are the operator
1140
+ configuration roots.
1141
+ """
1142
+ needs = super().sandbox_requirements(profile, command, env, home)
1143
+ root = os.path.join(home, ".claude")
1144
+ config: list[str] = []
1145
+ home_config: list[str] = []
1146
+ auth = [
1147
+ AuthItem(AuthMode.ENV, name)
1148
+ for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")
1149
+ if env.get(name)
1150
+ ]
1151
+ env_file = env.get("CLAUDE_ENV_FILE", "")
1152
+ if env_file.startswith("/"):
1153
+ config.append(env_file)
1154
+ explicit = env.get("CLAUDE_CONFIG_DIR", "")
1155
+ if explicit.startswith("/"):
1156
+ auth.append(
1157
+ AuthItem(
1158
+ AuthMode.DIR_RW,
1159
+ "the Claude Code configuration directory ($CLAUDE_CONFIG_DIR)",
1160
+ explicit,
1161
+ env_name="CLAUDE_CONFIG_DIR",
1162
+ )
1163
+ )
1164
+ else:
1165
+ login = os.path.join(root, ".credentials.json")
1166
+ if os.path.isfile(login):
1167
+ auth.append(
1168
+ AuthItem(AuthMode.FILE_RO, "the Claude Code login", login, target=login)
1169
+ )
1170
+ settings = os.path.join(root, "settings.json")
1171
+ if os.path.isfile(settings):
1172
+ home_config.append(settings)
1173
+ return dataclasses.replace(
1174
+ needs,
1175
+ config=(*needs.config, *config),
1176
+ home_config=(*needs.home_config, *home_config),
1177
+ auth=tuple(auth),
1178
+ operator_config_roots=(root, os.path.join(home, ".claude.json")),
1179
+ )
1180
+
1181
+ @contextmanager
1182
+ def integration_isolation(self) -> Iterator[dict[str, str]]:
1183
+ """A :data:`CLAUDE_SHELL_CLEANER` file for this launch's
1184
+ ``CLAUDE_ENV_FILE``, a bash or zsh to run Bash tool commands in and no
1185
+ shell prefix (:func:`claude_isolation_env`): no function or alias of
1186
+ the operator's shell startup files runs in one (#165).
1187
+
1188
+ The directory (``mkdtemp``, mode 0700) is removed when the launch
1189
+ ends; one that cannot be made or written is an
1190
+ :class:`ExecutionError`, before anything is launched.
1191
+ """
1192
+ try:
1193
+ env_dir = tempfile.mkdtemp(prefix="autoforge-claude-")
1194
+ except OSError as exc:
1195
+ raise ExecutionError(f"cannot prepare the claude shell environment: {exc}") from exc
1196
+ try:
1197
+ env_file = os.path.join(env_dir, CLAUDE_ENV_FILE_NAME)
1198
+ try:
1199
+ fd = os.open(env_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
1200
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1201
+ handle.write(CLAUDE_SHELL_CLEANER)
1202
+ except OSError as exc:
1203
+ raise ExecutionError(f"cannot prepare the claude shell environment: {exc}") from exc
1204
+ yield claude_isolation_env(env_file, claude_shell(child_shell()))
1205
+ finally:
1206
+ shutil.rmtree(env_dir, ignore_errors=True)
1207
+
1208
+ def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
1209
+ """Refuse a launch while Claude Code's own configuration would set a
1210
+ variable over its isolation (:func:`claude_configuration_problems`):
1211
+ 2.1.296 applies the ``env`` of the user settings and of the global
1212
+ configuration over the environment it is started with, under
1213
+ ``--safe-mode`` too, so a ``GH_TOKEN`` there would replace the
1214
+ placeholder and a ``CLAUDE_ENV_FILE`` there the cleaner.
1215
+ """
1216
+ problems = claude_configuration_problems(self.launch_environment(env_allowlist))
1217
+ if problems:
1218
+ raise ConfigurationError(
1219
+ "refusing to launch claude: its own configuration sets variables over the "
1220
+ "environment AutoForge gives a launch, which would hand its commands a GitHub "
1221
+ "or git credential or let a shell startup file's functions and aliases run in "
1222
+ f"them: {'; '.join(problems)}. Remove them from that env (Claude Code reads "
1223
+ "the variables of the environment it is started in; set them there and allow "
1224
+ "them through execution.env_allowlist_extra unless they name a credential), "
1225
+ "or route these profiles to another provider (docs/configuration.md, "
1226
+ "'Agent environment')"
1227
+ )
1228
+
1229
+ def validate_profile(self, profile: ProfileConfig) -> None:
1230
+ super().validate_profile(profile)
1231
+ if profile.effort and profile.effort not in CLAUDE_EFFORTS:
1232
+ raise ConfigurationError(
1233
+ f"profile {profile.name!r}: claude --effort must be one of {CLAUDE_EFFORTS}, "
1234
+ f"got {profile.effort!r}"
1235
+ )
1236
+ output_format = self.output_format(profile)
1237
+ if output_format not in CLAUDE_OUTPUT_FORMATS:
1238
+ raise ConfigurationError(
1239
+ f"profile {profile.name!r}: claude output_format must be one of "
1240
+ f"{', '.join(CLAUDE_OUTPUT_FORMATS)} (got {output_format!r}): stream-json "
1241
+ "reports progress and is reduced to the final text, text is the final text "
1242
+ "alone with no progress, and json gives neither"
1243
+ )
1244
+ mode = profile.options.get("permission_mode", "bypassPermissions")
1245
+ if mode and mode not in CLAUDE_PERMISSION_MODES:
1246
+ raise ConfigurationError(
1247
+ f"profile {profile.name!r}: unknown claude permission_mode {mode!r}"
1248
+ )
1249
+ for arg in profile.extra_args:
1250
+ flag = _integration_flag(arg)
1251
+ if flag is not None:
1252
+ # The flag only, never its value: a settings string may hold a key.
1253
+ raise ConfigurationError(
1254
+ f"profile {profile.name!r}: claude extra_args cannot carry {flag}: it "
1255
+ "loads an integration (an MCP server, a plugin, hooks, an agent "
1256
+ "definition, a browser or IDE connection) or settings, which may hold "
1257
+ "a GitHub token of its own or set variables over the launch's, and "
1258
+ f"AutoForge launches Claude Code with {', '.join(CLAUDE_ISOLATION_FLAGS)} "
1259
+ "so that none applies (docs/configuration.md, 'Agent environment')"
1260
+ )
1261
+
1262
+ @staticmethod
1263
+ def output_format(profile: ProfileConfig) -> str:
1264
+ return profile.options.get("output_format") or CLAUDE_DEFAULT_OUTPUT_FORMAT
1265
+
1266
+ def reports_activity(self, profile: ProfileConfig) -> bool:
1267
+ # `text` prints the final text once, at exit, and nothing before it.
1268
+ return self.output_format(profile) == "stream-json"
1269
+
1270
+ def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
1271
+ argv = [profile.command or "claude", "-p"]
1272
+ output_format = self.output_format(profile)
1273
+ argv += ["--output-format", output_format]
1274
+ if output_format == "stream-json":
1275
+ # The CLI refuses stream-json under --print without it.
1276
+ argv.append("--verbose")
1277
+ if profile.model:
1278
+ argv += ["--model", profile.model]
1279
+ if profile.effort:
1280
+ argv += ["--effort", profile.effort]
1281
+ mode = profile.options.get("permission_mode", "bypassPermissions")
1282
+ if mode:
1283
+ argv += ["--permission-mode", mode]
1284
+ if _truthy(profile.options.get("session_persistence"), default=False) is False:
1285
+ argv.append("--no-session-persistence")
1286
+ # No hook, plugin or MCP server of the operator's or the repository's
1287
+ # configuration runs (#165): one may hold a GitHub token of its own.
1288
+ argv += CLAUDE_ISOLATION_FLAGS
1289
+ argv += list(profile.extra_args)
1290
+ # ``--`` guarantees the prompt is never parsed as an option.
1291
+ argv += ["--", prompt]
1292
+ return argv
1293
+
1294
+ def execute(self, req: AgentRequest) -> AgentExecutionResult:
1295
+ """Run the CLI; with stream-json, reduce its stream to the final text.
1296
+
1297
+ ``output_format: text`` is the one-shot path every adapter shares.
1298
+ With ``stream-json`` the child runs under the duplex handle with its
1299
+ stdin on ``/dev/null``: the handle already frames LF-terminated
1300
+ records against a per-record bound and drops a longer one through
1301
+ its LF without buffering it, which is exactly what this stream needs
1302
+ (a ``tool_result`` line quotes whole files), so a line callback in
1303
+ the one-shot executor would have duplicated that framer. Every line
1304
+ goes to :class:`~autoforge.claude_stream.ClaudeStream`, which emits
1305
+ progress and decides the outcome. The CLI's exit, not EOF, ends the
1306
+ wait, so a leftover holding stdout costs ADR 0002's exit grace and
1307
+ kill, never the timeout. ``stdout`` of the result is the last
1308
+ ``result`` text, tail-bounded as the executor's capture is; a failure
1309
+ inside the stream is ``provider_failure`` and a timeout wins over it.
1310
+ ``exit_code`` is always the real process status.
1311
+ """
1312
+ if self.output_format(req.profile) != "stream-json":
1313
+ return super().execute(req)
1314
+ emit = guarded(req.progress)
1315
+ monitor = _loop_monitor(req, emit)
1316
+ stream = ClaudeStream(cwd=req.cwd, emit=emit, loop=_ClockedLoop(monitor))
1317
+ self.integration_preflight(req.env_allowlist)
1318
+ with self.integration_isolation() as isolation:
1319
+ env = {**(req.env or {}), **isolation}
1320
+ command, description = self.sandboxed(req, self.build_command(req), env)
1321
+ duplex = DuplexRequest(
1322
+ command=command,
1323
+ cwd=req.cwd,
1324
+ env_allowlist=self.environment_allowlist(req),
1325
+ env=env,
1326
+ idle_timeout_seconds=req.idle_timeout_seconds,
1327
+ max_runtime_seconds=req.max_runtime_seconds,
1328
+ max_record_bytes=CLAUDE_MAX_RECORD_BYTES,
1329
+ max_pending_bytes=CLAUDE_MAX_PENDING_BYTES,
1330
+ # The prompt travels in argv; stdin stays /dev/null so the CLI
1331
+ # never waits on it.
1332
+ stdin_pipe=False,
1333
+ # Nothing an agent starts outlives its invocation (ADR 0002).
1334
+ contain_orphans=True,
1335
+ sandbox=description,
1336
+ )
1337
+ with start_duplex(duplex) as child:
1338
+ cut_short = _read_claude_stream(child, stream, monitor)
1339
+ res = child.finish()
1340
+ timed_out = cut_short or res.timed_out or res.stopped
1341
+ failure = stream.failure
1342
+ if stream.exited_early:
1343
+ failure = f"{failure} (exit {res.exit_code})"
1344
+ elif res.records_overflowed and failure is None:
1345
+ failure = "claude: stream-json lines arrived faster than they were read"
1346
+ summary = stream.summary()
1347
+ summary["failure"] = failure or ""
1348
+ text = stream.text if failure is None else None
1349
+ stdout, truncated = _keep_tail(encodable(text or ""), DEFAULT_MAX_OUTPUT_BYTES)
1350
+ result = AgentExecutionResult(
1351
+ command=list(res.command),
1352
+ exit_code=res.exit_code,
1353
+ stdout=stdout,
1354
+ stderr=res.stderr,
1355
+ started_at=res.started_at,
1356
+ finished_at=res.finished_at,
1357
+ timed_out=timed_out,
1358
+ provider=req.profile.provider,
1359
+ model=req.profile.model,
1360
+ effort=req.profile.effort,
1361
+ stdout_truncated=truncated,
1362
+ stderr_truncated=res.stderr_truncated,
1363
+ stdout_tail_offset=0,
1364
+ descendants_killed=res.descendants_killed,
1365
+ group_survived_kill=res.group_survived_kill,
1366
+ capture_abandoned=res.capture_abandoned,
1367
+ orphans_killed=res.orphans_killed,
1368
+ orphan_survived_kill=res.orphan_survived_kill,
1369
+ orphans_unchecked=res.orphans_unchecked,
1370
+ provider_failure=None if timed_out else failure,
1371
+ provider_summary=summary,
1372
+ timeout_limit=res.timeout_limit,
1373
+ last_activity_at=res.last_activity_at,
1374
+ sandbox=res.sandbox,
1375
+ )
1376
+ result = _finish_loop(result, monitor, killed=res.stopped)
1377
+ # A refusal never ends a -p run (claude_stream.py): the agent goes on
1378
+ # without what it asked for, so a failure is never the refusal's alone.
1379
+ return _name_refused(result, stream.refused, stream.refused_count, self.name, "Claude Code")
1380
+
1381
+
1382
+ def _read_claude_stream(
1383
+ child: DuplexChild, stream: ClaudeStream, loop: LoopMonitor | None = None
1384
+ ) -> bool:
1385
+ """Feed every stdout outcome to ``stream`` through its end; True on the deadline.
1386
+
1387
+ The CLI's exit, not EOF, ends the wait: a descendant still holding its
1388
+ stdout, silent or writing, must not hold the invocation to the deadline
1389
+ (ADR 0002). After every read, a line included, the driver looks at the
1390
+ CLI; once it has exited, ``finish()`` gives what is left the exit grace
1391
+ and kills it, keeping the CLI's own status, and the rest of stdout,
1392
+ queued up to its end by then, is fed and validated like every line
1393
+ before it. A read that meets the deadline after the CLI's exit does the
1394
+ same (``read_line`` looks at the CLI first), so only a CLI still running
1395
+ at the deadline is a timeout. A verdict of ``loop`` (#194) on a CLI
1396
+ still running stops it, and the caller's ``finish()`` reports
1397
+ ``stopped``.
1398
+ """
1399
+ while True:
1400
+ item = child.read_line(timeout=CLAUDE_EXIT_POLL_SECONDS)
1401
+ if isinstance(item, Record):
1402
+ stream.feed(item.data)
1403
+ elif isinstance(item, Oversize):
1404
+ stream.oversize(item.limit)
1405
+ elif isinstance(item, Fragment):
1406
+ stream.framing_error("stdout ended inside an unterminated line")
1407
+ elif isinstance(item, Overflow):
1408
+ stream.framing_error("stdout lines arrived faster than they were read")
1409
+ return False
1410
+ elif isinstance(item, Eof):
1411
+ stream.stream_ended()
1412
+ return False
1413
+ elif item.deadline_exceeded:
1414
+ return True
1415
+ if loop is not None and loop.verdict is not None and child.stop():
1416
+ return False
1417
+ if child.exited():
1418
+ child.finish()
1419
+
1420
+
1421
+ def opencode_isolation_env(config_dir: str) -> dict[str, str]:
1422
+ """What an OpenCode launch sets so that it reads no configuration of the
1423
+ operator's or the repository's (#165): no flag of 2.0.26 disables an MCP
1424
+ server or a plugin, and either may hold a GitHub token of its own.
1425
+
1426
+ - ``OPENCODE_CONFIG_DIR``: the global configuration directory, instead
1427
+ of ``$XDG_CONFIG_HOME/opencode``; ``config_dir`` is empty, so no
1428
+ ``opencode.json(c)`` and no ``plugin(s)/`` directory is read from it.
1429
+ - ``OPENCODE_CONFIG_PROJECT_DISABLE`` and its older name
1430
+ ``OPENCODE_DISABLE_PROJECT_CONFIG``: no ``opencode.json(c)``,
1431
+ ``.opencode/``, ``.claude/`` or ``.agents/`` of the launch directory or
1432
+ of any directory above it (the search does not stop at the worktree, so
1433
+ it would reach the operator's checkout and home). OpenCode reads the
1434
+ first name and falls back to the second, so setting both leaves no
1435
+ value of the operator's for either in force.
1436
+ - ``OPENCODE_CONFIG`` and ``OPENCODE_CONFIG_CONTENT`` empty: no explicit
1437
+ file and no inline document, whatever the operator's shell passed
1438
+ through ``OPENCODE_*``.
1439
+
1440
+ The model credentials (``opencode auth login``, kept in the data
1441
+ directory) and the providers' API-key variables are untouched; so is
1442
+ the organization configuration kept with them, which no variable turns
1443
+ off and which refuses the launch instead
1444
+ (:func:`opencode_organization_problems`).
1445
+ """
1446
+ return {
1447
+ "OPENCODE_CONFIG_DIR": config_dir,
1448
+ "OPENCODE_CONFIG_PROJECT_DISABLE": "1",
1449
+ "OPENCODE_DISABLE_PROJECT_CONFIG": "1",
1450
+ "OPENCODE_CONFIG": "",
1451
+ "OPENCODE_CONFIG_CONTENT": "",
1452
+ }
1453
+
1454
+
1455
+ # Where OpenCode 2.0.26 keeps the organizations connected to it (a
1456
+ # ``.well-known/opencode`` integration, read from the build): the ``kv``
1457
+ # row ``wellknown:sources`` of its database lists their origins. Every
1458
+ # launch, ``Config.load`` fetches each one's manifest and configuration
1459
+ # again and, without a usable credential for it (``opencode auth logout``
1460
+ # removes only the credential), falls back to the copies cached in the same
1461
+ # table; either may name an MCP server or a plugin holding a GitHub
1462
+ # credential of its own, and no variable or flag keeps it from loading.
1463
+ # ``auth.json``, the credential file of earlier releases, is imported when
1464
+ # a database is created: an entry of ``"type": "wellknown"`` there becomes
1465
+ # a source.
1466
+ OPENCODE_WELLKNOWN_SOURCES = "wellknown:sources"
1467
+ OPENCODE_LEGACY_AUTH_FILE = "auth.json"
1468
+ OPENCODE_MAX_AUTH_BYTES = 1024 * 1024
1469
+ # How long reading the database may wait for an OpenCode writing to it.
1470
+ OPENCODE_DATABASE_TIMEOUT_SECONDS = 5
1471
+
1472
+
1473
+ def opencode_data_dir(env: Mapping[str, str]) -> Path:
1474
+ """The data directory of an OpenCode launch with ``env``:
1475
+ ``$XDG_DATA_HOME/opencode``, else ``~/.local/share/opencode`` (``HOME``,
1476
+ else the password database's home), where 2.0.26 keeps its database."""
1477
+ base = env.get("XDG_DATA_HOME")
1478
+ if not base:
1479
+ home = env.get("HOME")
1480
+ if not home:
1481
+ try:
1482
+ home = pwd.getpwuid(os.getuid()).pw_dir
1483
+ except KeyError:
1484
+ home = "/"
1485
+ base = os.path.join(home, ".local", "share")
1486
+ return Path(base, "opencode")
1487
+
1488
+
1489
+ def opencode_databases(env: Mapping[str, str], data_dir: Path) -> list[Path]:
1490
+ """The databases an OpenCode launch with ``env`` may open: the file
1491
+ ``OPENCODE_DB`` names (relative to ``data_dir``; ``:memory:`` is none),
1492
+ else each ``opencode*.db`` in ``data_dir`` (the name depends on the
1493
+ build's release channel)."""
1494
+ named = env.get("OPENCODE_DB")
1495
+ if named is not None:
1496
+ return [] if named == ":memory:" else [data_dir / named]
1497
+ return sorted(data_dir.glob("opencode*.db"))
1498
+
1499
+
1500
+ def _not_a_regular_file(path: Path) -> str | None:
1501
+ """Why ``path``, which exists, is not opened (a FIFO would block the
1502
+ launch forever, a directory has nothing to read); None for a file."""
1503
+ if path.is_file():
1504
+ return None
1505
+ return f"{path} could not be checked (not a regular file)"
1506
+
1507
+
1508
+ def _opencode_database_problems(database: Path) -> list[str]:
1509
+ """The organizations ``database`` lists, or that it could not be read.
1510
+
1511
+ Opened read-only (a URI with ``mode=ro``), never created or written;
1512
+ only the sources row is read, and only the number of origins is
1513
+ reported, never one.
1514
+ """
1515
+ if not database.exists():
1516
+ return []
1517
+ refused = _not_a_regular_file(database)
1518
+ if refused:
1519
+ return [refused]
1520
+ try:
1521
+ uri = f"{database.resolve().as_uri()}?mode=ro"
1522
+ with closing(
1523
+ sqlite3.connect(uri, uri=True, timeout=OPENCODE_DATABASE_TIMEOUT_SECONDS)
1524
+ ) as db:
1525
+ tables = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'kv'")
1526
+ if tables.fetchone() is None:
1527
+ return []
1528
+ row = db.execute(
1529
+ "SELECT value FROM kv WHERE key = ?", (OPENCODE_WELLKNOWN_SOURCES,)
1530
+ ).fetchone()
1531
+ except (sqlite3.Error, OSError, ValueError) as exc:
1532
+ return [f"{database} could not be checked ({type(exc).__name__})"]
1533
+ if row is None:
1534
+ return []
1535
+ try:
1536
+ sources = json.loads(row[0])
1537
+ except (TypeError, ValueError):
1538
+ sources = None
1539
+ if sources == []:
1540
+ return []
1541
+ if not isinstance(sources, list):
1542
+ return [f"{database} could not be checked (an unreadable list of organizations)"]
1543
+ return [f"{len(sources)} organization(s) connected to OpenCode in {database}"]
1544
+
1545
+
1546
+ def _opencode_legacy_auth_problems(data_dir: Path) -> list[str]:
1547
+ """An organization in the legacy ``auth.json``, or that it could not be read.
1548
+
1549
+ Parsed in memory only: no value of the file is ever reported.
1550
+ """
1551
+ path = data_dir / OPENCODE_LEGACY_AUTH_FILE
1552
+ if not path.exists():
1553
+ return []
1554
+ refused = _not_a_regular_file(path)
1555
+ if refused:
1556
+ return [refused]
1557
+ try:
1558
+ with path.open("rb") as handle:
1559
+ data = handle.read(OPENCODE_MAX_AUTH_BYTES + 1)
1560
+ except FileNotFoundError:
1561
+ return []
1562
+ except OSError as exc:
1563
+ return [f"{path} could not be checked ({type(exc).__name__})"]
1564
+ if len(data) > OPENCODE_MAX_AUTH_BYTES:
1565
+ return [f"{path} is larger than {OPENCODE_MAX_AUTH_BYTES} bytes and was not checked"]
1566
+ try:
1567
+ entries = json.loads(data)
1568
+ except ValueError:
1569
+ return [f"{path} could not be checked (not JSON)"]
1570
+ if isinstance(entries, dict) and any(
1571
+ isinstance(entry, dict) and entry.get("type") == "wellknown" for entry in entries.values()
1572
+ ):
1573
+ return [
1574
+ f'an organization (a "wellknown" entry) in {path}, which OpenCode imports '
1575
+ "into every database it creates"
1576
+ ]
1577
+ return []
1578
+
1579
+
1580
+ def opencode_organization_problems(env: Mapping[str, str]) -> list[str]:
1581
+ """What, in the data an OpenCode launch with ``env`` reads, would make it
1582
+ load an organization's configuration (empty: nothing would).
1583
+
1584
+ ``env`` is the environment the launch inherits (``HOME``,
1585
+ ``XDG_DATA_HOME`` and ``OPENCODE_DB`` decide where 2.0.26 looks). A
1586
+ database or file that exists but cannot be read is a problem too: a
1587
+ launch is refused rather than assumed clean.
1588
+ """
1589
+ data_dir = opencode_data_dir(env)
1590
+ problems: list[str] = []
1591
+ for database in opencode_databases(env, data_dir):
1592
+ problems += _opencode_database_problems(database)
1593
+ problems += _opencode_legacy_auth_problems(data_dir)
1594
+ return problems
1595
+
1596
+
1597
+ class OpenCodeProvider(AgentProvider):
1598
+ name = "opencode"
1599
+ # OpenCode's own settings plus the API keys of the model providers it
1600
+ # routes to; a provider not listed here is added through
1601
+ # ``execution.env_allowlist_extra``.
1602
+ environment_names = ("OPENCODE_*", "OPENAI_*", "ANTHROPIC_*", "GEMINI_*", "GOOGLE_*")
1603
+ option_keys = ("output_format", "auto_approve")
1604
+ isolation_names = tuple(sorted(opencode_isolation_env("")))
1605
+ path_names = ("OPENCODE_CONFIG_DIR", "OPENCODE_DB")
1606
+
1607
+ def sandbox_requirements(
1608
+ self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
1609
+ ) -> ProviderRequirements:
1610
+ """OpenCode's declaration (ADR 0005 D7.1, D13.1).
1611
+
1612
+ The launch's empty ``OPENCODE_CONFIG_DIR`` writable (it is the
1613
+ launch's own, removed afterwards). An absolute ``OPENCODE_DB``: its
1614
+ directory writable at its own path (``dir-rw``: the database holds
1615
+ the login and is rewritten), refused when it is or lies in the
1616
+ operator's data directory (D7.2). A relative or ``:memory:`` one
1617
+ names a database inside the private home. The providers' API keys
1618
+ are ``env`` items. The configuration, data and cache directories
1619
+ OpenCode loads settings, organizations and code from are the
1620
+ operator configuration roots.
1621
+ """
1622
+ needs = super().sandbox_requirements(profile, command, env, home)
1623
+ state: list[str] = []
1624
+ auth = [AuthItem(AuthMode.ENV, name) for name in env if name.endswith("_API_KEY")]
1625
+ config_dir = env.get("OPENCODE_CONFIG_DIR", "")
1626
+ if config_dir.startswith("/"):
1627
+ state.append(config_dir)
1628
+ database = env.get("OPENCODE_DB", "")
1629
+ if database.startswith("/"):
1630
+ auth.append(
1631
+ AuthItem(
1632
+ AuthMode.DIR_RW,
1633
+ "the OpenCode database directory ($OPENCODE_DB)",
1634
+ os.path.dirname(database),
1635
+ )
1636
+ )
1637
+ config_home = env.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
1638
+ cache_home = env.get("XDG_CACHE_HOME") or os.path.join(home, ".cache")
1639
+ roots = (
1640
+ os.path.join(config_home, "opencode"),
1641
+ str(opencode_data_dir({**env, "HOME": home})),
1642
+ os.path.join(cache_home, "opencode"),
1643
+ )
1644
+ return dataclasses.replace(
1645
+ needs,
1646
+ state=(*needs.state, *state),
1647
+ auth=tuple(auth),
1648
+ operator_config_roots=roots,
1649
+ )
1650
+
1651
+ @contextmanager
1652
+ def integration_isolation(self) -> Iterator[dict[str, str]]:
1653
+ """An empty global configuration directory, for this launch only, and
1654
+ no project, explicit or inline configuration (:func:`opencode_isolation_env`).
1655
+
1656
+ The directory (``mkdtemp``, mode 0700) is removed when the launch
1657
+ ends, whatever OpenCode wrote into it; one that cannot be made is an
1658
+ :class:`ExecutionError`, before anything is launched.
1659
+ """
1660
+ try:
1661
+ config_dir = tempfile.mkdtemp(prefix="autoforge-opencode-")
1662
+ except OSError as exc:
1663
+ raise ExecutionError(f"cannot prepare the opencode configuration: {exc}") from exc
1664
+ try:
1665
+ yield opencode_isolation_env(config_dir)
1666
+ finally:
1667
+ shutil.rmtree(config_dir, ignore_errors=True)
1668
+
1669
+ def data_dir(self, env_allowlist: tuple[str, ...] | None) -> Path:
1670
+ """The data directory a launch with ``env_allowlist`` uses (:func:`opencode_data_dir`)."""
1671
+ return opencode_data_dir(self.launch_environment(env_allowlist))
1672
+
1673
+ def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
1674
+ """Refuse a launch while OpenCode would load an organization's
1675
+ configuration (:func:`opencode_organization_problems`): 2.0.26 loads
1676
+ it ahead of every configuration file, and no variable of
1677
+ :func:`opencode_isolation_env` reaches it.
1678
+ """
1679
+ problems = opencode_organization_problems(self.launch_environment(env_allowlist))
1680
+ if problems:
1681
+ raise ConfigurationError(
1682
+ "refusing to launch opencode: it would load the configuration of an "
1683
+ "organization connected to it (a .well-known/opencode integration), which can "
1684
+ "name an MCP server or a plugin holding a GitHub credential of its own, and "
1685
+ f"nothing keeps it from loading: {'; '.join(problems)}. Give AutoForge's "
1686
+ "launches an OpenCode database without one (set OPENCODE_DB, which every "
1687
+ "launch inherits, to a file of their own and log in to the model provider "
1688
+ "with it set, or to ':memory:' with the provider's API-key variable), remove "
1689
+ 'a "wellknown" entry from auth.json, or route these profiles to another '
1690
+ "provider (docs/configuration.md, 'Agent environment')"
1691
+ )
1692
+
1693
+ def validate_profile(self, profile: ProfileConfig) -> None:
1694
+ super().validate_profile(profile)
1695
+ if "/" not in profile.model:
1696
+ raise ConfigurationError(
1697
+ f"profile {profile.name!r}: opencode model must be 'provider/model' "
1698
+ f"(e.g. openai/gpt-5.6-luna), got {profile.model!r}"
1699
+ )
1700
+ if "#" in profile.model:
1701
+ raise ConfigurationError(
1702
+ f"profile {profile.name!r}: opencode model must not carry a '#variant' "
1703
+ f"suffix (got {profile.model!r}); set the variant as the profile's effort"
1704
+ )
1705
+ fmt = profile.options.get("output_format", "default") or "default"
1706
+ if fmt != "default":
1707
+ raise ConfigurationError(
1708
+ f"profile {profile.name!r}: opencode output_format must be 'default' so the "
1709
+ "CONTROL_RESULT block reaches stdout verbatim (the json event stream escapes "
1710
+ "it and has no final result record)"
1711
+ )
1712
+
1713
+ def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
1714
+ """The argv, without the prompt: it goes on stdin (:meth:`stdin_payload`)."""
1715
+ argv = [profile.command or "opencode", "run", "--standalone"]
1716
+ if profile.model:
1717
+ variant = f"#{profile.effort}" if profile.effort else ""
1718
+ argv += ["-m", profile.model + variant]
1719
+ argv += ["--format", profile.options.get("output_format", "default") or "default"]
1720
+ if _truthy(profile.options.get("auto_approve"), default=False):
1721
+ argv.append("--auto")
1722
+ argv += list(profile.extra_args)
1723
+ return argv
1724
+
1725
+ def stdin_payload(self, req: AgentRequest) -> bytes:
1726
+ # A lone surrogate (legal in the JSON GitHub returns, not in UTF-8)
1727
+ # becomes U+FFFD rather than failing the launch.
1728
+ return encodable(req.prompt).encode("utf-8")
1729
+
1730
+ def execute(self, req: AgentRequest) -> AgentExecutionResult:
1731
+ result = super().execute(req)
1732
+ if (
1733
+ result.exit_code == 1
1734
+ and not result.timed_out
1735
+ and not result.stderr_truncated
1736
+ and req.profile.model.partition("/")[0] == "openai"
1737
+ ):
1738
+ errors = _opencode_run_errors(result.stderr)
1739
+ if errors and all(e in _OPENCODE_OPENAI_TRANSIENT_ERRORS for e in errors):
1740
+ reasons = dict.fromkeys(_OPENCODE_OPENAI_TRANSIENT_ERRORS[e] for e in errors)
1741
+ result.transient_failure = "opencode: " + ", ".join(reasons)
1742
+ refused = _opencode_refused_requests(result.stderr)
1743
+ ended_by_it = ""
1744
+ if (
1745
+ refused
1746
+ and result.exit_code == 1
1747
+ and result.provider_failure is None
1748
+ and not result.stderr_truncated
1749
+ and not _opencode_run_errors(result.stderr)
1750
+ ):
1751
+ # An exit 1 with no run-level error of its own: what the 2.0.x
1752
+ # builds did once they had refused a permission (module docstring).
1753
+ remedy = (
1754
+ "Keep the issue's work inside the worktree, or change the issue so the agent "
1755
+ "need not ask; options.auto_approve: true approves every permission no rule "
1756
+ "denies, and is dangerous"
1757
+ if any(kind is RequestKind.PERMISSION for kind, _ in refused)
1758
+ else "Change the issue so the agent need not ask"
1759
+ )
1760
+ if any(kind is RequestKind.QUESTION for kind, _ in refused):
1761
+ remedy += "; a question cannot be answered until #126 lands"
1762
+ ended_by_it = (
1763
+ f"OpenCode refused {'them' if len(refused) > 1 else 'it'} and exited 1 with no "
1764
+ f"error of its own. {remedy}. 'resume' starts a fresh session, which may ask "
1765
+ f"again ({REFUSED_REQUEST_GUIDE})"
1766
+ )
1767
+ return _name_refused(
1768
+ result, refused, len(refused), self.name, "OpenCode", ended_by_it=ended_by_it
1769
+ )
1770
+
1771
+
1772
+ # How OpenCode 2.0.26 prints an error on stderr: `ESC[91mESC[1mError: ESC[0m`
1773
+ # and the message's first line (no terminal check), and a failed tool: its
1774
+ # `ESC[0m✗ ESC[0m<title> failed` line, then the tool's error in the same form.
1775
+ _OPENCODE_ERROR_LINE_RE = re.compile(r"\x1b\[91m\x1b\[1mError: \x1b\[0m(.*)")
1776
+ _OPENCODE_TOOL_FAILED_LINE_RE = re.compile(r"\x1b\[0m✗ \x1b\[0m.* failed")
1777
+ # The run-level errors that make an `openai/*` exit 1 transient, each the
1778
+ # whole message OpenCode prints, mapped to the fixed reason reported for it:
1779
+ # the socket to OpenAI closed under a running turn, normally (1000, #164) or
1780
+ # with the policy code (1008, #226), and OpenAI's generic server error, whose
1781
+ # own text says to retry (#226). A message that only starts the same way
1782
+ # (another `server_error` text, one naming a request ID) is not listed.
1783
+ _OPENCODE_OPENAI_TRANSIENT_ERRORS = {
1784
+ "WebSocket closed with code 1000": "OpenAI WebSocket closed with code 1000",
1785
+ "WebSocket closed with code 1008": "OpenAI WebSocket closed with code 1008",
1786
+ (
1787
+ "server_error: An error occurred while processing your request. You can retry "
1788
+ "your request, or contact us through our help center at help.openai.com if the "
1789
+ "error persists."
1790
+ ): "OpenAI server_error",
1791
+ }
1792
+
1793
+
1794
+ # How OpenCode prints a request it refuses (#258; 2.1.4, run against a
1795
+ # loopback stand-in, and read from its build). A permission an agent's rule
1796
+ # asks for, without `--auto`: `ESC[93mESC[1m! ESC[0mpermission requested:
1797
+ # <permission> (<patterns, comma-separated>); auto-rejecting`. A question,
1798
+ # cancelled: the question tool's failed line, then this as its error line.
1799
+ _OPENCODE_PERMISSION_LINE_RE = re.compile(
1800
+ r"\x1b\[93m\x1b\[1m! \x1b\[0mpermission requested: (\S+) \((.*)\); auto-rejecting"
1801
+ )
1802
+ _OPENCODE_QUESTION_CANCELLED = (
1803
+ "This non-interactive run cannot ask the user questions, so the question was cancelled. "
1804
+ "Continue without an answer; make reasonable assumptions and state them."
1805
+ )
1806
+
1807
+
1808
+ def _opencode_refused_requests(stderr: str) -> list[tuple[RequestKind, str]]:
1809
+ """The requests OpenCode printed on ``stderr`` that it refused, in order:
1810
+ each permission line, with its permission and patterns as the subject,
1811
+ and each failed tool whose error is the cancelled question's (no subject:
1812
+ OpenCode prints no question text). Lines as in :func:`_opencode_run_errors`;
1813
+ text that is not such a line on its own (a diff, a file, a command's
1814
+ output that quotes one) is no request."""
1815
+ refused: list[tuple[RequestKind, str]] = []
1816
+ previous = ""
1817
+ for line in stderr.split("\n"):
1818
+ line = line.removesuffix("\r")
1819
+ permission = _OPENCODE_PERMISSION_LINE_RE.fullmatch(line)
1820
+ error = _OPENCODE_ERROR_LINE_RE.fullmatch(line)
1821
+ if permission:
1822
+ subject = clean(f"{permission[1]}: {permission[2]}", MAX_REFUSED_SUBJECT_CHARS)
1823
+ refused.append((RequestKind.PERMISSION, subject))
1824
+ elif (
1825
+ error
1826
+ and error[1] == _OPENCODE_QUESTION_CANCELLED
1827
+ and _OPENCODE_TOOL_FAILED_LINE_RE.fullmatch(previous)
1828
+ ):
1829
+ refused.append((RequestKind.QUESTION, ""))
1830
+ previous = line
1831
+ return refused
1832
+
1833
+
1834
+ def _opencode_run_errors(stderr: str) -> list[str]:
1835
+ """The run-level errors OpenCode printed on ``stderr``, in order: each
1836
+ error line's message, except a failed tool's (the line right after its
1837
+ ``✗ ... failed`` line), which does not fail the run. Text that is not
1838
+ such a line, wherever it says ``Error:``, is not an error. Lines end
1839
+ where OpenCode ends them (``os.EOL``), not at every character
1840
+ :meth:`str.splitlines` breaks on."""
1841
+ errors: list[str] = []
1842
+ previous = ""
1843
+ for line in stderr.split("\n"):
1844
+ line = line.removesuffix("\r")
1845
+ error = _OPENCODE_ERROR_LINE_RE.fullmatch(line)
1846
+ if error and not _OPENCODE_TOOL_FAILED_LINE_RE.fullmatch(previous):
1847
+ errors.append(error.group(1))
1848
+ previous = line
1849
+ return errors
1850
+
1851
+
1852
+ # #186: the CLI whose argv and stdin delivery the adapter speaks. No upper bound.
1853
+ OPENCODE_MIN_VERSION = (2, 0, 0)
1854
+ # What `opencode --version` prints: `opencode v2.0.23`; 1.x printed `1.18.34`.
1855
+ _OPENCODE_VERSION_RE = re.compile(r"(?:opencode )?v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?")
1856
+
1857
+
1858
+ def parse_opencode_version(text: str) -> tuple[int, int, int] | None:
1859
+ """``(major, minor, patch)`` from ``opencode --version`` output, or None
1860
+ when unreadable; a pre-release sorts below its release, as for Pi."""
1861
+ return _parse_version(text, _OPENCODE_VERSION_RE)
1862
+
1863
+
1864
+ PI_EFFORTS = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
1865
+ # ADR 0003 §2.8: the release its RPC, model and auth facts were read from.
1866
+ # No upper bound.
1867
+ PI_MIN_VERSION = (1, 0, 0)
1868
+ PI_MODEL_PROVIDER_RE = re.compile(r"[a-z0-9-]+")
1869
+ # What `pi --version` prints: the bare version, optionally a pre-release.
1870
+ _PI_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?")
1871
+
1872
+
1873
+ def parse_pi_version(text: str) -> tuple[int, int, int] | None:
1874
+ """``(major, minor, patch)`` from ``pi --version`` output, or None when unreadable.
1875
+
1876
+ A pre-release sorts below its release: ``1.0.0-rc.1`` reads as
1877
+ ``(1, 0, -1)``, which is below the 1.0.0 minimum, and ``1.1.0-rc.1`` as
1878
+ ``(1, 1, -1)``, which is above it.
1879
+ """
1880
+ return _parse_version(text, _PI_VERSION_RE)
1881
+
1882
+
1883
+ def _parse_version(text: str, pattern: re.Pattern[str]) -> tuple[int, int, int] | None:
1884
+ lines = text.strip().splitlines()
1885
+ match = pattern.fullmatch(lines[0].strip()) if len(lines) == 1 else None
1886
+ if match is None:
1887
+ return None
1888
+ major, minor, patch = (int(part) for part in match.group(1, 2, 3))
1889
+ if match.group(4):
1890
+ patch -= 1
1891
+ return major, minor, patch
1892
+
1893
+
1894
+ # ADR 0003 §3: each stdio round trip around the prompt (`get_state`,
1895
+ # `get_available_models`, `get_last_assistant_text`) is bounded inside the
1896
+ # invocation's limits. The first two also cover Pi's start-up (Node, its
1897
+ # settings and its model catalog), hence more than a bare round trip needs.
1898
+ PI_ROUND_TRIP_SECONDS = 15.0
1899
+ # How long an `abort` is given to settle a running prompt before stdin is
1900
+ # closed. It follows a limit falling due and never shortens it (#193): the
1901
+ # group is killed this long after the limit, Pi's own exit included.
1902
+ PI_ABORT_SECONDS = 5.0
1903
+ # The per-record bound is sized for one message: a final text past the
1904
+ # stdout bound must still arrive whole in one `get_last_assistant_text`
1905
+ # record so that its tail can be kept, and JSON escaping can double a text.
1906
+ # It is not Pi's worst case, which has no bound: `agent_end` repeats every
1907
+ # message of the run (207 KB for a 200 KB prompt and one `read` on Pi
1908
+ # 1.0.1, and compaction does not shrink it), and `turn_end` a turn's
1909
+ # assistant message and all its tool results. Those two are dropped past
1910
+ # the bound and tolerated from their leading bytes, since the reducer needs
1911
+ # neither's content; any other record past it is a protocol violation
1912
+ # (`PiConversation.oversize`, #144). Memory stays at the bound either way.
1913
+ PI_MAX_RECORD_BYTES = 2 * DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024
1914
+ PI_MAX_PENDING_BYTES = 2 * PI_MAX_RECORD_BYTES
1915
+
1916
+
1917
+ # Pi's built-in tools (dist/core/tools/index.js at 1.0.1). Pi drops a name
1918
+ # it does not know without a word, so a profile's `tools` is checked here.
1919
+ PI_TOOLS = ("read", "bash", "edit", "write", "grep", "find", "ls", "powershell")
1920
+ # The tool set a profile gets when it names none: the implementation phases
1921
+ # write the tree, review and EPIC maintenance read it. `bash` stays in both
1922
+ # (an agent needs `git` and `gh`), so the read set is defence in depth, not a
1923
+ # write barrier (docs/pi-policy.md §6).
1924
+ PI_WRITE_PROFILES = ("analyze_execute", "fix", "replan_reexecute")
1925
+ PI_WRITE_TOOLS = "read,bash,edit,write"
1926
+ PI_READ_TOOLS = "read,bash"
1927
+ _PI_TOOLS_RE = re.compile(r"[a-z]+(,[a-z]+)*")
1928
+ # Provider API keys Pi would use (or hand to its tools) in place of a
1929
+ # stored ChatGPT sign-in. A profile with `require_oauth` refuses to launch
1930
+ # when one of them would reach Pi (docs/pi-policy.md §8).
1931
+ PI_API_KEY_NAMES = ("OPENAI_API_KEY",)
1932
+ # What Pi loads from its agent directory and from every directory between
1933
+ # `/` and the cwd whatever the resource flags say (docs/pi-policy.md §4-5):
1934
+ # the first context file name found in each directory (none with
1935
+ # `--no-context-files`), and the agent directory's system prompt files
1936
+ # (always; no flag turns them off). `doctor` reports their presence only.
1937
+ PI_CONTEXT_FILES = ("AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD")
1938
+ PI_SYSTEM_PROMPT_FILES = ("SYSTEM.md", "APPEND_SYSTEM.md")
1939
+ PI_DEFAULT_AGENT_DIR = "~/.pi/agent"
1940
+ # The per-launch `pi auth check` reads one file and prints one line.
1941
+ PI_AUTH_CHECK_SECONDS = 60
1942
+ PI_AUTH_CHECK_MAX_OUTPUT_BYTES = 64 * 1024
1943
+
1944
+ PI_AUTH_STATUSES = ("ready", "not_ready", "invalid")
1945
+ PI_AUTH_REASONS = (
1946
+ "provider_not_found",
1947
+ "credentials_not_configured",
1948
+ "credential_not_available",
1949
+ "invalid_state",
1950
+ )
1951
+ PI_AUTH_TYPES = ("api_key", "oauth")
1952
+ # `pi auth check` exits 0 for ready, 1 for not_ready, 2 for invalid.
1953
+ _PI_AUTH_EXIT_CODES = {"ready": 0, "not_ready": 1, "invalid": 2}
1954
+ # The `provider` field is quoted back to the operator: a bounded run of
1955
+ # printable ASCII (a provider id, or the model string on `invalid`).
1956
+ _PI_AUTH_PROVIDER_RE = re.compile(r"[\x21-\x7e]{1,200}")
1957
+
1958
+
1959
+ @dataclass(frozen=True)
1960
+ class PiAuthCheck:
1961
+ """The parsed ``pi auth check --json`` result: these four fields, nothing else."""
1962
+
1963
+ status: str
1964
+ provider: str
1965
+ reason: str = ""
1966
+ auth_type: str = ""
1967
+
1968
+
1969
+ def parse_pi_auth_check(stdout: str, exit_code: int) -> PiAuthCheck:
1970
+ """Parse ``pi auth check --json`` strictly, or raise :class:`ValueError`.
1971
+
1972
+ The output is one JSON object ``{status, provider, reason?, authType?}``
1973
+ and the exit code agrees with ``status``. Anything else is contract
1974
+ drift and fails closed. The error text never quotes the output: with
1975
+ the wrong flags it could hold a credential (``--credentials`` adds one).
1976
+ """
1977
+ lines = stdout.strip().splitlines()
1978
+ if len(lines) != 1:
1979
+ raise ValueError(f"expected one JSON line on stdout, got {len(lines)} line(s)")
1980
+ try:
1981
+ data = json.loads(lines[0])
1982
+ except json.JSONDecodeError:
1983
+ raise ValueError("stdout is not JSON") from None
1984
+ if not isinstance(data, dict):
1985
+ raise ValueError("stdout is not a JSON object")
1986
+ unknown = sorted(set(data) - {"status", "provider", "reason", "authType"})
1987
+ if unknown:
1988
+ # Counted, not named: a key is Pi output too, and may carry a secret.
1989
+ raise ValueError(f"{len(unknown)} unexpected field(s)")
1990
+ status = data.get("status")
1991
+ provider = data.get("provider")
1992
+ reason = data.get("reason", "")
1993
+ auth_type = data.get("authType", "")
1994
+ if status not in PI_AUTH_STATUSES:
1995
+ raise ValueError("unknown or missing 'status'")
1996
+ if not isinstance(provider, str) or not _PI_AUTH_PROVIDER_RE.fullmatch(provider):
1997
+ raise ValueError("missing or unreadable 'provider'")
1998
+ if status == "ready":
1999
+ if reason != "" or auth_type not in PI_AUTH_TYPES:
2000
+ raise ValueError("'ready' without a known 'authType', or with a 'reason'")
2001
+ elif auth_type != "" or reason not in PI_AUTH_REASONS:
2002
+ raise ValueError(f"{status!r} without a known 'reason', or with an 'authType'")
2003
+ if exit_code != _PI_AUTH_EXIT_CODES[status]:
2004
+ raise ValueError(f"exit code {exit_code} does not match status {status!r}")
2005
+ return PiAuthCheck(status=status, provider=provider, reason=reason, auth_type=auth_type)
2006
+
2007
+
2008
+ class PiProvider(AgentProvider):
2009
+ """Pi (``@earendil-works/pi-coding-agent``) over its stdio RPC mode (ADR 0003).
2010
+
2011
+ This class carries Pi's static surface (profile validation, the argv,
2012
+ the environment names, the read-only ``doctor`` probes) and the driver
2013
+ that couples the conversation in :mod:`autoforge.pi_rpc` to the duplex
2014
+ child handle. The driver owns time and the process; the reducer owns
2015
+ every Pi command and event name.
2016
+ """
2017
+
2018
+ name = "pi"
2019
+ # Pi's own process settings (docs/environment-variables.md at v1.0.0):
2020
+ # where its config and credential store live, and the network,
2021
+ # version-check and telemetry switches (docs/pi-policy.md §8). Explicit
2022
+ # names, never a prefix: Pi exports `PI_SESSION_*`, `PI_PROVIDER`,
2023
+ # `PI_MODEL` to its tool children, and a provider API key
2024
+ # (`OPENAI_API_KEY`, ...) reaches Pi only through
2025
+ # `execution.env_allowlist_extra`, never by default.
2026
+ environment_names = (
2027
+ "PI_CODING_AGENT_DIR",
2028
+ "PI_OFFLINE",
2029
+ "PI_SKIP_VERSION_CHECK",
2030
+ "PI_TELEMETRY",
2031
+ )
2032
+ option_keys = ("require_oauth", "tools", "context_files")
2033
+ path_names = ("PI_CODING_AGENT_DIR",)
2034
+
2035
+ def sandbox_requirements(
2036
+ self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
2037
+ ) -> ProviderRequirements:
2038
+ """Pi's declaration (ADR 0005 D7.1, D13.1; docs/pi-policy.md §8).
2039
+
2040
+ An absolute ``PI_CODING_AGENT_DIR``: that directory writable at its
2041
+ own path (``dir-rw``: Pi writes ``auth.json`` and
2042
+ ``models-store.json`` there by rename at startup), refused when it is
2043
+ or lies in ``~/.pi/agent``, the operator configuration root (D7.2).
2044
+ Without one, Pi finds no login inside, and its auth preflight says
2045
+ so. A provider key is an ``env`` item.
2046
+ """
2047
+ needs = super().sandbox_requirements(profile, command, env, home)
2048
+ auth = [AuthItem(AuthMode.ENV, name) for name in PI_API_KEY_NAMES if env.get(name)]
2049
+ agent_dir = env.get("PI_CODING_AGENT_DIR", "")
2050
+ if agent_dir.startswith("/"):
2051
+ auth.append(
2052
+ AuthItem(
2053
+ AuthMode.DIR_RW,
2054
+ "Pi's agent directory ($PI_CODING_AGENT_DIR)",
2055
+ agent_dir,
2056
+ env_name="PI_CODING_AGENT_DIR",
2057
+ )
2058
+ )
2059
+ default = os.path.join(home, PI_DEFAULT_AGENT_DIR.removeprefix("~/"))
2060
+ return dataclasses.replace(needs, auth=tuple(auth), operator_config_roots=(default,))
2061
+
2062
+ def validate_profile(self, profile: ProfileConfig) -> None:
2063
+ super().validate_profile(profile)
2064
+ where = f"profile {profile.name!r}"
2065
+ if profile.effort not in PI_EFFORTS:
2066
+ raise ConfigurationError(
2067
+ f"{where}: pi effort (--thinking) is required and must be one of "
2068
+ f"{', '.join(PI_EFFORTS)}, got {profile.effort!r}"
2069
+ )
2070
+ problem = _pi_model_problem(profile.model)
2071
+ if problem:
2072
+ raise ConfigurationError(
2073
+ f"{where}: pi model must be '<pi-provider>/<model-id>' "
2074
+ f"(e.g. openai/gpt-5.6-terra): {problem}, got {profile.model!r}"
2075
+ )
2076
+ if profile.extra_args:
2077
+ raise ConfigurationError(
2078
+ f"{where}: pi takes no extra_args (got {profile.extra_args!r}); every Pi "
2079
+ "flag that matters is set by the adapter, and a pass-through could re-enable "
2080
+ "project trust, put an API key in argv, load code or prompts, or keep a session"
2081
+ )
2082
+ for key in ("require_oauth", "context_files"):
2083
+ if profile.options.get(key, "true") not in ("true", "false"):
2084
+ raise ConfigurationError(
2085
+ f"{where}: pi option {key} must be true or false, got {profile.options[key]!r}"
2086
+ )
2087
+ if "tools" in profile.options:
2088
+ problem = _pi_tools_problem(profile.options["tools"])
2089
+ if problem:
2090
+ raise ConfigurationError(
2091
+ f"{where}: pi option tools must be a comma-separated list of Pi's "
2092
+ f"built-in tools ({', '.join(PI_TOOLS)}): {problem}, "
2093
+ f"got {profile.options['tools']!r}"
2094
+ )
2095
+
2096
+ def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
2097
+ # The prompt is deliberately absent: it reaches Pi as a JSON `prompt`
2098
+ # record on stdin (ADR 0003 §2.7), never in argv. Every flag before
2099
+ # `--tools` is unconditional; no option and no `extra_args` (which a
2100
+ # Pi profile cannot have) removes one (docs/pi-policy.md §1-3).
2101
+ command = [
2102
+ self.command(profile),
2103
+ "--mode",
2104
+ "rpc",
2105
+ "--no-session",
2106
+ # Project trust off, whatever `trust.json` saved for the path:
2107
+ # without it a trusted project's `.pi/SYSTEM.md` and packages load
2108
+ # even with every resource flag below.
2109
+ "--no-approve",
2110
+ # No executable resource from the project, the agent dir or Pi's
2111
+ # built-in extensions (native MCP included).
2112
+ "--no-extensions",
2113
+ "--no-skills",
2114
+ "--no-prompt-templates",
2115
+ "--no-themes",
2116
+ # No package install or update at startup (npm or git, from the
2117
+ # global settings), no catalog refresh; model calls still go out.
2118
+ "--offline",
2119
+ "--tools",
2120
+ self.tools(profile),
2121
+ ]
2122
+ if not self.context_files(profile):
2123
+ command.append("--no-context-files")
2124
+ return [*command, "--model", profile.model, "--thinking", profile.effort]
2125
+
2126
+ def __init__(
2127
+ self,
2128
+ runner: Runner | None = None,
2129
+ *,
2130
+ round_trip_seconds: float = PI_ROUND_TRIP_SECONDS,
2131
+ abort_seconds: float = PI_ABORT_SECONDS,
2132
+ max_text_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
2133
+ ) -> None:
2134
+ super().__init__(runner)
2135
+ self.round_trip_seconds = round_trip_seconds
2136
+ self.abort_seconds = abort_seconds
2137
+ self.max_text_bytes = max_text_bytes
2138
+
2139
+ def execute(self, req: AgentRequest) -> AgentExecutionResult:
2140
+ """One ``pi --mode rpc`` child, one prompt, one final text (ADR 0003).
2141
+
2142
+ The child runs under the request's idle limit and ceiling, which
2143
+ the duplex handle turns into one deadline that Pi's output keeps
2144
+ moving (#193). When a limit falls due with the conversation still
2145
+ running, and not before, it is cut short: the deadline is pinned
2146
+ (so what Pi writes while it settles cannot stretch it), ``abort``
2147
+ is sent, stdin is closed, and the duplex handle kills the group if
2148
+ Pi has not exited ``abort_seconds`` after the limit. The result is
2149
+ then ``timed_out`` whatever Pi's exit status, and names the limit
2150
+ that fell due. A failure inside the protocol is
2151
+ ``provider_failure``; its abort runs under the deadline pinned when
2152
+ the failure is acted on. ``exit_code`` is always the real process
2153
+ status. A spawn failure raises :class:`ExecutionError`.
2154
+
2155
+ With ``require_oauth`` (the default) the launch is refused before
2156
+ anything is spawned when a provider API key would reach Pi, and a
2157
+ ``pi auth check`` in the same environment must report a ChatGPT
2158
+ sign-in first; a refused check is a ``provider_failure`` naming it,
2159
+ and Pi is never started (docs/pi-policy.md §8).
2160
+ """
2161
+ allowlist = self.environment_allowlist(req)
2162
+ if self.require_oauth(req.profile):
2163
+ self._refuse_api_keys(req, allowlist)
2164
+ refused = self._auth_preflight(req, allowlist)
2165
+ if refused is not None:
2166
+ return refused
2167
+ emit = guarded(req.progress)
2168
+ monitor = _loop_monitor(req, emit)
2169
+ conversation = PiConversation(
2170
+ model=req.profile.model,
2171
+ thinking=req.profile.effort,
2172
+ prompt=req.prompt,
2173
+ round_trip_seconds=self.round_trip_seconds,
2174
+ emit=emit,
2175
+ cwd=req.cwd,
2176
+ loop=monitor,
2177
+ )
2178
+ command, description = self.sandboxed(req, self.build_command(req), req.env)
2179
+ duplex = DuplexRequest(
2180
+ command=command,
2181
+ cwd=req.cwd,
2182
+ env_allowlist=allowlist,
2183
+ env=req.env,
2184
+ idle_timeout_seconds=req.idle_timeout_seconds,
2185
+ max_runtime_seconds=req.max_runtime_seconds,
2186
+ # The abort comes after a limit, so the kill waits for it.
2187
+ wind_down_seconds=self.abort_seconds,
2188
+ max_record_bytes=PI_MAX_RECORD_BYTES,
2189
+ max_pending_bytes=PI_MAX_PENDING_BYTES,
2190
+ # Pi's bash tool starts every command detached (ADR 0003 §6).
2191
+ contain_orphans=True,
2192
+ sandbox=description,
2193
+ )
2194
+ with start_duplex(duplex) as child:
2195
+ cut_short = self._converse(child, conversation, monitor)
2196
+ # The limit that fell due when the conversation was cut short:
2197
+ # pinned by then, so Pi's own exit inside the abort keeps it.
2198
+ cut_limit = child.deadline_limit if cut_short else ""
2199
+ if cut_short:
2200
+ conversation.deadline_reached()
2201
+ if conversation.failure is not None:
2202
+ # Every abort ends by a fixed deadline: what Pi writes while
2203
+ # it settles, or after, cannot stretch its shutdown.
2204
+ child.pin_deadline()
2205
+ self._abort(child, conversation, self.abort_seconds)
2206
+ child.close_stdin()
2207
+ res = child.finish()
2208
+ timed_out = cut_short or res.timed_out or res.stopped
2209
+ failure = conversation.failure
2210
+ if conversation.exited_early:
2211
+ failure = f"{failure} (exit {res.exit_code})"
2212
+ elif res.records_overflowed and failure is None:
2213
+ failure = "pi: protocol violation: stdout records arrived faster than they were read"
2214
+ summary = conversation.summary()
2215
+ if failure is not None:
2216
+ summary["failure"] = failure
2217
+ text = conversation.text if failure is None else None
2218
+ stdout, truncated = _keep_tail(encodable(text or ""), self.max_text_bytes)
2219
+ result = AgentExecutionResult(
2220
+ command=list(res.command),
2221
+ exit_code=res.exit_code,
2222
+ stdout=stdout,
2223
+ stderr=res.stderr,
2224
+ started_at=res.started_at,
2225
+ finished_at=res.finished_at,
2226
+ timed_out=timed_out,
2227
+ provider=req.profile.provider,
2228
+ model=req.profile.model,
2229
+ effort=req.profile.effort,
2230
+ stdout_truncated=truncated,
2231
+ stderr_truncated=res.stderr_truncated,
2232
+ stdout_tail_offset=0,
2233
+ descendants_killed=res.descendants_killed,
2234
+ group_survived_kill=res.group_survived_kill,
2235
+ capture_abandoned=res.capture_abandoned,
2236
+ orphans_killed=res.orphans_killed,
2237
+ orphan_survived_kill=res.orphan_survived_kill,
2238
+ orphans_unchecked=res.orphans_unchecked,
2239
+ # A timeout wins over whatever the protocol said (ADR 0003 §2.6).
2240
+ provider_failure=None if timed_out else failure,
2241
+ provider_summary=summary,
2242
+ timeout_limit=(res.timeout_limit or cut_limit) if timed_out else "",
2243
+ last_activity_at=res.last_activity_at,
2244
+ sandbox=res.sandbox,
2245
+ )
2246
+ return _finish_loop(result, monitor, killed=res.stopped)
2247
+
2248
+ def _refuse_api_keys(self, req: AgentRequest, allowlist: tuple[str, ...] | None) -> None:
2249
+ """Raise :class:`ExecutionError` when a set provider API key would reach Pi."""
2250
+ problem = self.api_key_problem(req.profile, allowlist)
2251
+ if problem:
2252
+ raise ExecutionError(problem)
2253
+
2254
+ @staticmethod
2255
+ def api_key_problem(profile: ProfileConfig, allowlist: tuple[str, ...] | None) -> str:
2256
+ """Why ``profile`` may not launch with ``allowlist``'s API keys, or ''.
2257
+
2258
+ A profile with ``require_oauth`` must not hand Pi (and through it
2259
+ every command Pi's `bash` tool runs) a provider API key: Pi would
2260
+ prefer a stored sign-in, so the key would sit in the tools'
2261
+ environment for nothing, or bill the run when the sign-in lapses.
2262
+ Only the names are checked and named, never a value.
2263
+ """
2264
+ if not PiProvider.require_oauth(profile):
2265
+ return ""
2266
+ env = os.environ if allowlist is None else select_environment(allowlist)
2267
+ present = [name for name in PI_API_KEY_NAMES if env.get(name)]
2268
+ if not present:
2269
+ return ""
2270
+ return (
2271
+ f"profile {profile.name!r} requires Pi's ChatGPT sign-in (options.require_oauth), "
2272
+ f"but {', '.join(present)} is set and would reach Pi through the environment "
2273
+ "allow-list; remove it from execution.env_allowlist_extra (or unset it), or set "
2274
+ "options.require_oauth: false to run on an API key"
2275
+ )
2276
+
2277
+ def _auth_preflight(
2278
+ self, req: AgentRequest, allowlist: tuple[str, ...] | None
2279
+ ) -> AgentExecutionResult | None:
2280
+ """Run ``pi auth check`` as the launch would; None when it allows the launch.
2281
+
2282
+ Read-only (``--no-refresh``), in the cwd and environment the launch
2283
+ uses, judged by :meth:`auth_verdict`: ``ready`` with authType
2284
+ ``oauth`` for the configured provider. A refusal is returned as a
2285
+ result whose ``provider_failure`` quotes only the check's fields;
2286
+ its output is never kept.
2287
+ """
2288
+ # In the launch's own sandbox, with the same declaration: the check
2289
+ # must find the credential exactly where Pi will (ADR 0005 D3.1).
2290
+ command, description = self.sandboxed(req, self.auth_check_command(req.profile), req.env)
2291
+ res = self._runner(
2292
+ ExecutionRequest(
2293
+ command=command,
2294
+ cwd=req.cwd,
2295
+ timeout_seconds=PI_AUTH_CHECK_SECONDS,
2296
+ max_output_bytes=PI_AUTH_CHECK_MAX_OUTPUT_BYTES,
2297
+ env_allowlist=allowlist,
2298
+ env=req.env,
2299
+ contain_orphans=True,
2300
+ sandbox=description,
2301
+ )
2302
+ )
2303
+ if res.timed_out:
2304
+ detail = f"`pi auth check` did not answer within {PI_AUTH_CHECK_SECONDS}s"
2305
+ elif res.stdout_truncated:
2306
+ detail = "`pi auth check` printed more than a result"
2307
+ else:
2308
+ ok, detail = self.auth_verdict(req.profile, res.exit_code, res.stdout)
2309
+ if ok:
2310
+ return None
2311
+ return AgentExecutionResult(
2312
+ command=list(res.command),
2313
+ exit_code=res.exit_code,
2314
+ stdout="",
2315
+ stderr="",
2316
+ started_at=res.started_at,
2317
+ finished_at=res.finished_at,
2318
+ provider=req.profile.provider,
2319
+ model=req.profile.model,
2320
+ effort=req.profile.effort,
2321
+ descendants_killed=res.descendants_killed,
2322
+ group_survived_kill=res.group_survived_kill,
2323
+ capture_abandoned=res.capture_abandoned,
2324
+ orphans_killed=res.orphans_killed,
2325
+ orphan_survived_kill=res.orphan_survived_kill,
2326
+ orphans_unchecked=res.orphans_unchecked,
2327
+ provider_failure=f"pi: the auth preflight refused the launch: {detail}",
2328
+ provider_summary={"auth_preflight": "refused"},
2329
+ sandbox=res.sandbox,
2330
+ )
2331
+
2332
+ @staticmethod
2333
+ def _send(child: DuplexChild, conversation: PiConversation, lines: list[bytes]) -> None:
2334
+ for line in lines:
2335
+ try:
2336
+ child.send_line(line)
2337
+ except ChildStdinClosedError:
2338
+ conversation.stdin_closed()
2339
+ return
2340
+
2341
+ def _converse(
2342
+ self,
2343
+ child: DuplexChild,
2344
+ conversation: PiConversation,
2345
+ loop: LoopMonitor | None = None,
2346
+ ) -> bool:
2347
+ """Drive the conversation to its outcome; True when a limit cut it short.
2348
+
2349
+ The conversation is cut short as the child's limit falls due, which
2350
+ Pi's output keeps moving, so it is recomputed on every turn; once
2351
+ cut short the deadline is pinned, the abort window after the limit.
2352
+ A verdict of ``loop`` (#194) stops a Pi still running at once, with
2353
+ no ``abort``: the run is discarded, as on a timeout, and the caller's
2354
+ ``finish()`` reports ``stopped``.
2355
+ """
2356
+ try:
2357
+ self._send(child, conversation, conversation.start(time.monotonic()))
2358
+ while not conversation.done:
2359
+ now = time.monotonic()
2360
+ due = child.limit_due()
2361
+ if now >= due:
2362
+ child.pin_deadline()
2363
+ return True
2364
+ conversation.tick(now)
2365
+ if conversation.done:
2366
+ break
2367
+ wake = conversation.next_wakeup
2368
+ until = due if wake is None else min(due, wake)
2369
+ item = child.read_line(timeout=max(0.0, until - now))
2370
+ if isinstance(item, Record):
2371
+ replies = conversation.feed(item.data, time.monotonic())
2372
+ if loop is not None and loop.verdict is not None and child.stop():
2373
+ return False
2374
+ self._send(child, conversation, replies)
2375
+ elif isinstance(item, Oversize):
2376
+ conversation.oversize(item.head, item.limit)
2377
+ elif isinstance(item, Fragment):
2378
+ conversation.framing_error("stdout ended inside an unterminated record")
2379
+ elif isinstance(item, Overflow):
2380
+ conversation.framing_error("stdout records arrived faster than they were read")
2381
+ elif isinstance(item, Eof):
2382
+ conversation.stream_ended()
2383
+ elif item.deadline_exceeded:
2384
+ return True
2385
+ except ExecutionTimeoutError:
2386
+ return True # a write overran the deadline; the group is gone
2387
+ return False
2388
+
2389
+ def _abort(self, child: DuplexChild, conversation: PiConversation, window: float) -> None:
2390
+ """Ask Pi to abort a running prompt and give it ``window`` seconds to settle.
2391
+
2392
+ Never SIGINT: Pi has no SIGINT handler in RPC mode (ADR 0003 §2.8).
2393
+ Dialogs that arrive meanwhile are still answered.
2394
+ """
2395
+ end = time.monotonic() + window
2396
+ try:
2397
+ self._send(child, conversation, conversation.begin_abort(time.monotonic()))
2398
+ while not conversation.abort_settled:
2399
+ now = time.monotonic()
2400
+ if now >= end:
2401
+ return
2402
+ item = child.read_line(timeout=end - now)
2403
+ if isinstance(item, Record):
2404
+ replies = conversation.feed(item.data, time.monotonic())
2405
+ self._send(child, conversation, replies)
2406
+ elif isinstance(item, Eof | Overflow):
2407
+ return
2408
+ elif isinstance(item, Timeout) and item.deadline_exceeded:
2409
+ return
2410
+ except ExecutionTimeoutError:
2411
+ return
2412
+
2413
+ # -- read-only probes for `autoforge doctor` ---------------------------
2414
+ @staticmethod
2415
+ def command(profile: ProfileConfig) -> str:
2416
+ return profile.command or "pi"
2417
+
2418
+ @staticmethod
2419
+ def require_oauth(profile: ProfileConfig) -> bool:
2420
+ return profile.options.get("require_oauth", "true") != "false"
2421
+
2422
+ @staticmethod
2423
+ def context_files(profile: ProfileConfig) -> bool:
2424
+ return profile.options.get("context_files", "true") != "false"
2425
+
2426
+ @staticmethod
2427
+ def tools(profile: ProfileConfig) -> str:
2428
+ """The ``--tools`` list: the profile's ``tools``, or its phase's default."""
2429
+ configured = profile.options.get("tools")
2430
+ if configured:
2431
+ return configured
2432
+ return PI_WRITE_TOOLS if profile.name in PI_WRITE_PROFILES else PI_READ_TOOLS
2433
+
2434
+ @staticmethod
2435
+ def model_provider(profile: ProfileConfig) -> str:
2436
+ return profile.model.split("/", 1)[0]
2437
+
2438
+ def version_command(self, profile: ProfileConfig) -> list[str]:
2439
+ return [self.command(profile), "--version"]
2440
+
2441
+ def auth_check_command(self, profile: ProfileConfig) -> list[str]:
2442
+ """Whether Pi holds a credential for the model's provider, read-only.
2443
+
2444
+ ``--no-refresh`` makes Pi open its credential store read-only (no
2445
+ OAuth refresh is written), and ``--credentials``, which would print
2446
+ the credential, is never passed.
2447
+ """
2448
+ return [
2449
+ self.command(profile),
2450
+ "auth",
2451
+ "check",
2452
+ "--model",
2453
+ profile.model,
2454
+ "--json",
2455
+ "--no-refresh",
2456
+ ]
2457
+
2458
+ def auth_verdict(self, profile: ProfileConfig, exit_code: int, stdout: str) -> tuple[bool, str]:
2459
+ """``(ok, detail)`` for the result of :meth:`auth_check_command`.
2460
+
2461
+ The detail quotes only the four fields of the result, never stdout
2462
+ or stderr. ``ready`` shows that Pi holds a credential for the
2463
+ provider, not that the model exists or that the credential may use
2464
+ it: Pi builds a placeholder for an id outside its catalog and still
2465
+ reports ``ready`` (ADR 0003 §4 item 2).
2466
+ """
2467
+ command = " ".join(self.auth_check_command(profile))
2468
+ try:
2469
+ check = parse_pi_auth_check(stdout, exit_code)
2470
+ except ValueError as exc:
2471
+ return False, (
2472
+ f"`pi auth check` exited {exit_code} without a result AutoForge can read "
2473
+ f"({exc}); Pi's output is not shown, run `{command}` yourself to see it"
2474
+ )
2475
+ configured = self.model_provider(profile)
2476
+ fields = f"status {check.status}, provider {check.provider!r}"
2477
+ if check.reason:
2478
+ fields += f", reason {check.reason}"
2479
+ if check.auth_type:
2480
+ fields += f", authType {check.auth_type}"
2481
+ # An `invalid` result names what was asked for (the model, when Pi
2482
+ # failed before resolving it), so only the others name a provider.
2483
+ if check.status != "invalid" and check.provider != configured:
2484
+ return False, (
2485
+ f"{fields}: Pi resolved model {profile.model!r} to provider "
2486
+ f"{check.provider!r}, not the configured {configured!r}"
2487
+ )
2488
+ if check.status == "ready":
2489
+ if check.auth_type == "api_key" and self.require_oauth(profile):
2490
+ return False, (
2491
+ f"{fields}: this profile requires Pi's ChatGPT sign-in; Pi resolved an "
2492
+ "API key (set options.require_oauth: false to run on an API key)"
2493
+ )
2494
+ # `;`, not `:`: the redactor reads `api_key: <word>` as a key's value.
2495
+ return True, (
2496
+ f"{fields}; authenticated for provider {configured!r} only; whether this "
2497
+ f"credential may use {profile.model!r} cannot be checked read-only (doctor "
2498
+ "neither refreshes nor uses it), the first real run is the proof"
2499
+ )
2500
+ if check.reason == "credentials_not_configured":
2501
+ if configured in ("openai", "openai-codex"):
2502
+ remedy = (
2503
+ f"run `pi`, then `/login {configured}` (an operator step; AutoForge "
2504
+ "never logs in for you)"
2505
+ )
2506
+ else:
2507
+ remedy = (
2508
+ f"give Pi a credential for {configured!r}; an API key reaches Pi only "
2509
+ "through execution.env_allowlist_extra"
2510
+ )
2511
+ return False, f"{fields}: Pi has no credential for {configured!r}; {remedy}"
2512
+ if check.reason == "provider_not_found":
2513
+ return False, (
2514
+ f"{fields}: Pi does not know the provider {configured!r} named by "
2515
+ f"model {profile.model!r}"
2516
+ )
2517
+ if check.reason == "credential_not_available":
2518
+ return False, f"{fields}: Pi could not produce a usable credential for {configured!r}"
2519
+ return False, (
2520
+ f"{fields}: Pi's model or credential configuration is invalid; run `{command}` "
2521
+ "yourself to see why"
2522
+ )
2523
+
2524
+
2525
+ def _keep_tail(text: str, limit: int) -> tuple[str, bool]:
2526
+ """``text``, or its last ``limit`` UTF-8 bytes and True when it is longer.
2527
+
2528
+ A character the cut splits decodes to U+FFFD, as the executor's capture
2529
+ does at its bound.
2530
+ """
2531
+ data = text.encode("utf-8")
2532
+ if len(data) <= limit:
2533
+ return text, False
2534
+ return data[-limit:].decode("utf-8", errors="replace"), True
2535
+
2536
+
2537
+ def _pi_tools_problem(value: str) -> str:
2538
+ """Why ``value`` is not a valid ``tools`` option, or ''."""
2539
+ if not _PI_TOOLS_RE.fullmatch(value):
2540
+ return "expected names separated by ',' with no spaces and no empty entry"
2541
+ names = value.split(",")
2542
+ unknown = [name for name in names if name not in PI_TOOLS]
2543
+ if unknown:
2544
+ return f"unknown tool(s) {', '.join(unknown)}"
2545
+ if len(set(names)) != len(names):
2546
+ return "a tool is listed twice"
2547
+ return ""
2548
+
2549
+
2550
+ def _pi_model_problem(model: str) -> str:
2551
+ """Why ``model`` is not a valid Pi ``provider/id`` (ADR 0003 §2.2), or ''."""
2552
+ if not model:
2553
+ return "it is empty"
2554
+ if any(ch.isspace() for ch in model):
2555
+ return "it contains whitespace"
2556
+ if model.count("/") != 1:
2557
+ return "it must contain exactly one '/'"
2558
+ provider, model_id = model.split("/")
2559
+ if not PI_MODEL_PROVIDER_RE.fullmatch(provider):
2560
+ return "the provider part must be non-empty and use only a-z, 0-9 and '-'"
2561
+ if not model_id:
2562
+ return "the model id is empty"
2563
+ if ":" in model and model.rsplit(":", 1)[1] in PI_EFFORTS:
2564
+ return "a ':<thinking>' suffix is not allowed; set the level with 'effort'"
2565
+ return ""
2566
+
2567
+
2568
+ # -- SDK providers (ADR 0007; policy: sdk_policy.py, #149) --------------------
2569
+ _SDK_MODEL_RE = re.compile(r"[A-Za-z0-9][^\s#]{0,127}")
2570
+ _OPENCODE_SDK_EFFORT_RE = re.compile(r"[a-z][a-z0-9_-]{0,31}")
2571
+
2572
+
2573
+ class _SdkProvider(AgentProvider):
2574
+ """What the two SDK adapters share: a fixed policy, no pass-through, and
2575
+ no launch until their helper lands.
2576
+
2577
+ ``command`` names the helper's interpreter (Python for ``claude-sdk``,
2578
+ Node for ``opencode-sdk``; ADR 0007 D2.3, D2.5) and is required: no
2579
+ default could name the operator's separate runtime environment.
2580
+ ``extra_args`` are refused: every option the helper gets is a field of
2581
+ the policy (:mod:`autoforge.sdk_policy`), and the SDKs' ``extra_args``
2582
+ and ``settings`` are the pass-through #149 closes. The launch allow-list
2583
+ holds exact names only, without any other provider's variables or a
2584
+ credential-shaped name (:func:`autoforge.sdk_policy.sdk_launch_allowlist`).
2585
+ """
2586
+
2587
+ # The issue whose helper launches this provider, the helper's
2588
+ # interpreter as ``command`` names it, and the decision that says so.
2589
+ transport_issue = ""
2590
+ interpreter = ""
2591
+ runtime_decision = ""
2592
+ # Foreign names the operator may still forward by listing them exactly.
2593
+ passthrough: tuple[str, ...] = ()
2594
+
2595
+ def validate_profile(self, profile: ProfileConfig) -> None:
2596
+ super().validate_profile(profile)
2597
+ where = f"profile {profile.name!r}"
2598
+ if not profile.command or any(ch.isspace() for ch in profile.command):
2599
+ raise ConfigurationError(
2600
+ f"{where}: {self.name} needs 'command', the {self.interpreter} of the "
2601
+ "separately installed runtime environment its helper runs in (ADR 0007 "
2602
+ f"{self.runtime_decision}), with no whitespace; got {profile.command!r}"
2603
+ )
2604
+ if profile.extra_args:
2605
+ # Never the arguments themselves: one may carry a settings
2606
+ # document or a key.
2607
+ raise ConfigurationError(
2608
+ f"{where}: {self.name} takes no extra_args ({len(profile.extra_args)} given): "
2609
+ "every SDK option, flag and setting is fixed by AutoForge's unattended policy "
2610
+ "(docs/sdk-policy.md), and a pass-through could load settings, hooks, MCP "
2611
+ "servers or plugins, or widen the tools"
2612
+ )
2613
+ self.policy(profile)
2614
+
2615
+ # -- to override ---------------------------------------------------------
2616
+ def policy(self, profile: ProfileConfig) -> ClaudeSdkPolicy | OpenCodeSdkPolicy:
2617
+ """``profile``'s policy, or :class:`ConfigurationError`."""
2618
+ raise NotImplementedError
2619
+
2620
+ def interaction_settings(self, profile: ProfileConfig) -> InteractionSettings | None:
2621
+ return interaction_settings(profile.options, f"profile {profile.name!r}")
2622
+
2623
+ def own_names(self, profile: ProfileConfig | None) -> tuple[str, ...]:
2624
+ """The variables this provider adds to the allow-list: its credential."""
2625
+ return self.environment_names
2626
+
2627
+ # -- shared --------------------------------------------------------------
2628
+ def _refuse_launch(self) -> ConfigurationError:
2629
+ return ConfigurationError(
2630
+ f"provider {self.name} cannot launch in this release: its policy is in place "
2631
+ f"(docs/sdk-policy.md), but its helper and transport are #{self.transport_issue}'s "
2632
+ "and have not landed. Route this profile to another provider"
2633
+ )
2634
+
2635
+ def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
2636
+ raise self._refuse_launch()
2637
+
2638
+ def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
2639
+ """Refused: nothing can launch yet, so the engine stops here, before
2640
+ a launch is charged."""
2641
+ raise self._refuse_launch()
2642
+
2643
+ def execute(self, req: AgentRequest) -> AgentExecutionResult:
2644
+ raise self._refuse_launch()
2645
+
2646
+ def launch_allowlist(self, base: tuple[str, ...]) -> tuple[str, ...]:
2647
+ """``base`` with every other provider's variables and every
2648
+ credential-shaped name removed and ``*`` prefixes expanded, plus this
2649
+ provider's own names (:func:`autoforge.sdk_policy.sdk_launch_allowlist`)."""
2650
+ return self._allowlist(base, None)
2651
+
2652
+ def environment_allowlist(self, req: AgentRequest) -> tuple[str, ...] | None:
2653
+ if req.env_allowlist is None:
2654
+ return None
2655
+ return self._allowlist(req.env_allowlist, req.profile)
2656
+
2657
+ def _allowlist(self, base: tuple[str, ...], profile: ProfileConfig | None) -> tuple[str, ...]:
2658
+ return sdk_launch_allowlist(
2659
+ base,
2660
+ self.own_names(profile),
2661
+ os.environ.keys(),
2662
+ passthrough=self.passthrough,
2663
+ )
2664
+
2665
+
2666
+ class ClaudeSdkProvider(_SdkProvider):
2667
+ """``claude-sdk``: the Claude Agent SDK in a helper process (ADR 0007; #151).
2668
+
2669
+ Authenticates with ``ANTHROPIC_API_KEY`` alone, never a claude.ai login
2670
+ (D10.2); a Bedrock, Vertex AI or Foundry route is not supported in this
2671
+ release (docs/sdk-policy.md §5).
2672
+ """
2673
+
2674
+ name = CLAUDE_SDK
2675
+ environment_names = (CLAUDE_SDK_CREDENTIAL,)
2676
+ option_keys = CLAUDE_SDK_OPTION_KEYS
2677
+ path_names = ("CLAUDE_CONFIG_DIR",)
2678
+ passthrough = CLAUDE_SDK_PASSTHROUGH
2679
+ transport_issue = "151"
2680
+ interpreter = "Python interpreter"
2681
+ runtime_decision = "D2.3"
2682
+
2683
+ def validate_profile(self, profile: ProfileConfig) -> None:
2684
+ super().validate_profile(profile)
2685
+ where = f"profile {profile.name!r}"
2686
+ if profile.effort and profile.effort not in CLAUDE_EFFORTS:
2687
+ raise ConfigurationError(
2688
+ f"{where}: {self.name} effort must be one of {', '.join(CLAUDE_EFFORTS)}, "
2689
+ f"got {profile.effort!r}"
2690
+ )
2691
+ if not _SDK_MODEL_RE.fullmatch(profile.model):
2692
+ raise ConfigurationError(
2693
+ f"{where}: {self.name} model must be a Claude model alias or id, with no "
2694
+ f"whitespace and no '#', got {profile.model!r}"
2695
+ )
2696
+
2697
+ def policy(self, profile: ProfileConfig) -> ClaudeSdkPolicy:
2698
+ return claude_sdk_policy(profile.name, profile.options)
2699
+
2700
+ def sandbox_requirements(
2701
+ self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
2702
+ ) -> ProviderRequirements:
2703
+ """``claude-sdk``'s declaration (ADR 0005 D7.1, D13.1; ADR 0007 D10.2).
2704
+
2705
+ ``ANTHROPIC_API_KEY`` is an ``env`` item; there is no login file and
2706
+ no keyring item, ever. The policy's ``CLAUDE_CONFIG_DIR``
2707
+ (:class:`autoforge.sdk_policy.ClaudeSdkRoots`) writable at its own
2708
+ path (``dir-rw``), refused when it is or lies in ``~/.claude``
2709
+ (D7.2). ``~/.claude`` and ``~/.claude.json`` are the operator
2710
+ configuration roots.
2711
+ """
2712
+ needs = super().sandbox_requirements(profile, command, env, home)
2713
+ auth = (
2714
+ [AuthItem(AuthMode.ENV, CLAUDE_SDK_CREDENTIAL)]
2715
+ if env.get(CLAUDE_SDK_CREDENTIAL)
2716
+ else []
2717
+ )
2718
+ config_dir = env.get("CLAUDE_CONFIG_DIR", "")
2719
+ if config_dir.startswith("/"):
2720
+ auth.append(
2721
+ AuthItem(
2722
+ AuthMode.DIR_RW,
2723
+ "the claude-sdk configuration and session root ($CLAUDE_CONFIG_DIR)",
2724
+ config_dir,
2725
+ env_name="CLAUDE_CONFIG_DIR",
2726
+ )
2727
+ )
2728
+ return dataclasses.replace(
2729
+ needs,
2730
+ auth=tuple(auth),
2731
+ operator_config_roots=(
2732
+ os.path.join(home, ".claude"),
2733
+ os.path.join(home, ".claude.json"),
2734
+ ),
2735
+ )
2736
+
2737
+
2738
+ class OpenCodeSdkProvider(_SdkProvider):
2739
+ """``opencode-sdk``: the OpenCode SDK in a Node bridge (ADR 0007; #152).
2740
+
2741
+ Its model credential is the one variable the profile names in
2742
+ ``options.credential_env``; no stored sign-in is read (D10.2).
2743
+ """
2744
+
2745
+ name = OPENCODE_SDK
2746
+ environment_names = ()
2747
+ option_keys = OPENCODE_SDK_OPTION_KEYS
2748
+ path_names = ("OPENCODE_CONFIG_DIR",)
2749
+ transport_issue = "152"
2750
+ interpreter = "Node executable"
2751
+ runtime_decision = "D2.5"
2752
+
2753
+ def validate_profile(self, profile: ProfileConfig) -> None:
2754
+ super().validate_profile(profile)
2755
+ where = f"profile {profile.name!r}"
2756
+ problem = opencode_model_problem(profile.model)
2757
+ if problem:
2758
+ raise ConfigurationError(
2759
+ f"{where}: {self.name} model must be an OpenCode model id: {problem}, "
2760
+ f"got {profile.model!r}"
2761
+ )
2762
+ if profile.effort and not _OPENCODE_SDK_EFFORT_RE.fullmatch(profile.effort):
2763
+ raise ConfigurationError(
2764
+ f"{where}: {self.name} effort must be a reasoning variant name (lower-case "
2765
+ f"letters, digits, '_' and '-'), got {profile.effort!r}"
2766
+ )
2767
+
2768
+ def policy(self, profile: ProfileConfig) -> OpenCodeSdkPolicy:
2769
+ return opencode_sdk_policy(profile.name, profile.options)
2770
+
2771
+ def own_names(self, profile: ProfileConfig | None) -> tuple[str, ...]:
2772
+ if profile is None:
2773
+ return ()
2774
+ return (self.policy(profile).credential_env,)
2775
+
2776
+ def sandbox_requirements(
2777
+ self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
2778
+ ) -> ProviderRequirements:
2779
+ """``opencode-sdk``'s declaration (ADR 0005 D7.1, D13.1; ADR 0007 D10.2).
2780
+
2781
+ The profile's ``credential_env`` is an ``env`` item, the only
2782
+ credential; no OpenCode store is projected. The policy's roots
2783
+ (:class:`autoforge.sdk_policy.OpenCodeSdkRoots`) are #152's to
2784
+ project. The operator's OpenCode configuration, data and cache
2785
+ directories are the operator configuration roots.
2786
+ """
2787
+ needs = super().sandbox_requirements(profile, command, env, home)
2788
+ credential = self.policy(profile).credential_env
2789
+ auth = (AuthItem(AuthMode.ENV, credential),) if env.get(credential) else ()
2790
+ return dataclasses.replace(
2791
+ needs,
2792
+ auth=auth,
2793
+ operator_config_roots=(
2794
+ os.path.join(home, ".config", "opencode"),
2795
+ os.path.join(home, ".local", "share", "opencode"),
2796
+ os.path.join(home, ".cache", "opencode"),
2797
+ os.path.join(home, ".opencode"),
2798
+ ),
2799
+ )
2800
+
2801
+
2802
+ ScriptHandler = Callable[[AgentRequest], str]
2803
+
2804
+
2805
+ class ScriptedProvider(AgentProvider):
2806
+ """Test/integration provider: returns canned stdout, never spawns a process.
2807
+
2808
+ ``script`` is either a list of stdout strings consumed in order, or a
2809
+ callable ``(AgentRequest) -> stdout``. Every request is recorded in
2810
+ ``calls`` so tests can assert on prompts, phases and cwd.
2811
+ """
2812
+
2813
+ name = "scripted"
2814
+
2815
+ def __init__(
2816
+ self,
2817
+ script: list[str] | ScriptHandler | None = None,
2818
+ exit_code: int = 0,
2819
+ stderr: str = "",
2820
+ ) -> None:
2821
+ super().__init__(runner=None)
2822
+ self._queue: list[str] = list(script) if isinstance(script, list) else []
2823
+ self._handler: ScriptHandler | None = script if callable(script) else None
2824
+ self.exit_code = exit_code
2825
+ self.stderr = stderr
2826
+ self.calls: list[AgentRequest] = []
2827
+ # The description each sandboxed request compiled to: the engine's
2828
+ # facts alone, since a scripted agent declares nothing (ADR 0005).
2829
+ self.descriptions: list[LaunchDescription] = []
2830
+
2831
+ def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
2832
+ return ["scripted-agent", f"--profile={profile.name}", "--", prompt]
2833
+
2834
+ def execute(self, req: AgentRequest) -> AgentExecutionResult:
2835
+ self.calls.append(req)
2836
+ if req.sandbox is not None:
2837
+ self.descriptions.append(req.sandbox.describe(ProviderRequirements(), req.env or {}))
2838
+ if self._handler is not None:
2839
+ stdout = self._handler(req)
2840
+ elif self._queue:
2841
+ stdout = self._queue.pop(0)
2842
+ else:
2843
+ stdout = ""
2844
+ return AgentExecutionResult(
2845
+ command=self.build_command(req),
2846
+ exit_code=self.exit_code,
2847
+ stdout=stdout,
2848
+ stderr=self.stderr,
2849
+ started_at="2026-01-01T00:00:00+00:00",
2850
+ finished_at="2026-01-01T00:00:01+00:00",
2851
+ provider="scripted",
2852
+ model=req.profile.model,
2853
+ effort=req.profile.effort,
2854
+ )
2855
+
2856
+
2857
+ _REGISTRY: dict[str, type[AgentProvider]] = {
2858
+ "claude": ClaudeCodeProvider,
2859
+ "opencode": OpenCodeProvider,
2860
+ "pi": PiProvider,
2861
+ "scripted": ScriptedProvider,
2862
+ CLAUDE_SDK: ClaudeSdkProvider,
2863
+ OPENCODE_SDK: OpenCodeSdkProvider,
2864
+ }
2865
+
2866
+
2867
+ def provider_for(profile: ProfileConfig, runner: Runner | None = None) -> AgentProvider:
2868
+ cls = _REGISTRY.get(profile.provider)
2869
+ if cls is None:
2870
+ raise ConfigurationError(
2871
+ f"profile {profile.name!r}: unknown provider {profile.provider!r} "
2872
+ f"(known: {', '.join(sorted(_REGISTRY))})"
2873
+ )
2874
+ if cls is ScriptedProvider:
2875
+ return ScriptedProvider()
2876
+ return cls(runner=runner)
2877
+
2878
+
2879
+ class ProviderRegistry:
2880
+ """Resolves providers per profile; allows injecting fakes per provider name."""
2881
+
2882
+ def __init__(
2883
+ self,
2884
+ runner: Runner | None = None,
2885
+ overrides: dict[str, AgentProvider] | None = None,
2886
+ ) -> None:
2887
+ self._runner = runner
2888
+ self._overrides = dict(overrides or {})
2889
+
2890
+ def get(self, profile: ProfileConfig) -> AgentProvider:
2891
+ if profile.provider in self._overrides:
2892
+ return self._overrides[profile.provider]
2893
+ return provider_for(profile, runner=self._runner)