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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/config.py
ADDED
|
@@ -0,0 +1,2178 @@
|
|
|
1
|
+
"""Controller configuration layer.
|
|
2
|
+
|
|
3
|
+
Model selection is controller configuration — never hard-coded shell
|
|
4
|
+
commands in the engine. Users edit a config file (YAML/TOML/JSON) to change
|
|
5
|
+
the real CLI command / model identifiers; logical profile names stay stable.
|
|
6
|
+
|
|
7
|
+
Logical execution profiles (routing semantics are fixed in ``profiles.py``)::
|
|
8
|
+
|
|
9
|
+
analyze_execute Claude Code / Fable / high
|
|
10
|
+
fix Claude Code / Fable / high
|
|
11
|
+
review_round_1 OpenCode / GPT 5.6 Luna / high
|
|
12
|
+
review_round_2_5 OpenCode / GPT 5.6 Terra / high
|
|
13
|
+
review_round_6_plus OpenCode / GPT 5.6 Sol / medium
|
|
14
|
+
update_epic (future milestone; gated)
|
|
15
|
+
|
|
16
|
+
MERGE has no agent profile: the controller itself runs ``gh pr merge``
|
|
17
|
+
(see ``merge:`` below and ``GitHubClient.merge_pr``), behind the merge gate.
|
|
18
|
+
|
|
19
|
+
The *real* model identifiers below were checked against the locally
|
|
20
|
+
installed CLIs (``claude --help``, ``opencode models``); change them in the
|
|
21
|
+
config file, never by editing routing code.
|
|
22
|
+
|
|
23
|
+
Supported config file formats:
|
|
24
|
+
.toml — stdlib tomllib (always available)
|
|
25
|
+
.json — stdlib json (always available)
|
|
26
|
+
.yaml/.yml — PyYAML if installed, else a minimal built-in subset parser
|
|
27
|
+
sufficient for the documented example file (nested maps with
|
|
28
|
+
2-space indent, lists with "- ", scalars, quoted strings).
|
|
29
|
+
|
|
30
|
+
The two YAML backends must never read the same file differently: what the
|
|
31
|
+
subset parser accepts it resolves exactly as PyYAML's YAML 1.1 implicit
|
|
32
|
+
resolvers do, and what it cannot resolve that way it refuses outright rather
|
|
33
|
+
than keeping as the string it looks like. See the note above ``_scalar``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import fnmatch
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
from collections.abc import Iterator
|
|
44
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from . import __prompt_version__
|
|
48
|
+
from .child_env import refuse_forbidden_env
|
|
49
|
+
from .errors import ConfigurationError, SandboxPolicyError, ValidationDefinitionError
|
|
50
|
+
from .evidence import SCOPE, check_definitions, check_scope
|
|
51
|
+
from .executor import MAX_DEADLINE_SECONDS, is_env_pattern
|
|
52
|
+
from .local_workspace import (
|
|
53
|
+
DEFAULT_MAX_BYTES,
|
|
54
|
+
DEFAULT_MAX_ENTRIES,
|
|
55
|
+
normalize_exclude_pattern,
|
|
56
|
+
)
|
|
57
|
+
from .sandbox import check_declared_path
|
|
58
|
+
|
|
59
|
+
CONFIG_VERSION = 1
|
|
60
|
+
|
|
61
|
+
# An agent is killed once it has written nothing for this long (#193): above
|
|
62
|
+
# the longest silent stretch a working agent has, which is usually a tool
|
|
63
|
+
# call that prints nothing until it returns (a long test run).
|
|
64
|
+
DEFAULT_IDLE_TIMEOUT_SECONDS = 900
|
|
65
|
+
# The wall-clock limit of each repository-defined command the controller
|
|
66
|
+
# runs itself (pre-merge verification, LOCAL validation).
|
|
67
|
+
DEFAULT_COMMAND_TIMEOUT_SECONDS = 1800
|
|
68
|
+
|
|
69
|
+
KNOWN_PROVIDERS = ("claude", "opencode", "pi", "scripted", "claude-sdk", "opencode-sdk")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Where a REMOTE run keeps its state, relative to the invocation directory.
|
|
73
|
+
# A LOCAL run defaults elsewhere -- see
|
|
74
|
+
# :meth:`autoforge.engine.ControllerEngine.bind_local_state_dir` -- because its
|
|
75
|
+
# fingerprint covers the whole working tree and runtime state must not be in it.
|
|
76
|
+
DEFAULT_STATE_DIR = ".autoforge"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ProfileConfig:
|
|
81
|
+
"""One logical execution profile -> concrete provider/model/effort.
|
|
82
|
+
|
|
83
|
+
``options`` carries provider-specific knobs the adapter understands
|
|
84
|
+
(e.g. ``permission_mode`` for Claude Code, ``auto_approve`` for
|
|
85
|
+
OpenCode, ``require_oauth`` for Pi, ``interaction_policy`` and its limits
|
|
86
|
+
for the SDK providers); each real adapter names the keys it accepts and
|
|
87
|
+
rejects any other. ``extra_args`` are appended verbatim before the prompt
|
|
88
|
+
(Pi and the SDK providers accept none: docs/sdk-policy.md).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
# "claude" | "opencode" | "pi" | "scripted" | "claude-sdk" | "opencode-sdk"
|
|
93
|
+
provider: str
|
|
94
|
+
model: str = ""
|
|
95
|
+
effort: str = "high"
|
|
96
|
+
command: str = ""
|
|
97
|
+
extra_args: list[str] = field(default_factory=list)
|
|
98
|
+
# The agent's limits (#193); ``None`` falls back to
|
|
99
|
+
# ``execution.default_idle_timeout_seconds`` /
|
|
100
|
+
# ``execution.default_max_runtime_seconds``. Resolved, together with what
|
|
101
|
+
# the provider can report, by :meth:`AutoForgeConfig.agent_limits`.
|
|
102
|
+
idle_timeout_seconds: int | None = None
|
|
103
|
+
max_runtime_seconds: int | None = None
|
|
104
|
+
options: dict[str, str] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def build_command(self, prompt: str) -> list[str]:
|
|
107
|
+
"""Render the real CLI argv via the provider adapter (see providers.py)."""
|
|
108
|
+
from .providers import provider_for # local import: providers depends on config
|
|
109
|
+
|
|
110
|
+
return provider_for(self).build_command_for(self, prompt)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# The keys a profile mapping under `profiles:` may contain. `options` is
|
|
114
|
+
# itself a mapping of provider-specific knobs; its *contents* are the
|
|
115
|
+
# provider adapter's to validate (each real adapter declares the keys it
|
|
116
|
+
# accepts, ``AgentProvider.option_keys``), its presence is checked here.
|
|
117
|
+
PROFILE_KEYS = (
|
|
118
|
+
"provider",
|
|
119
|
+
"model",
|
|
120
|
+
"effort",
|
|
121
|
+
"command",
|
|
122
|
+
"extra_args",
|
|
123
|
+
"idle_timeout_seconds",
|
|
124
|
+
"max_runtime_seconds",
|
|
125
|
+
"options",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# A profile key that used to be read, and where it went: rejected with that
|
|
129
|
+
# message rather than reinterpreted (see ``REMOVED_EXECUTION_KEYS``).
|
|
130
|
+
REMOVED_PROFILE_KEYS = {
|
|
131
|
+
"timeout_seconds": (
|
|
132
|
+
"'timeout_seconds' is no longer supported: an agent is now killed only after "
|
|
133
|
+
"making no progress (no output) for 'idle_timeout_seconds' (default: "
|
|
134
|
+
"execution.default_idle_timeout_seconds, {idle}s), with an optional wall-clock "
|
|
135
|
+
"ceiling in 'max_runtime_seconds'. Replace the key with one or both"
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
EXECUTION_KEYS = (
|
|
140
|
+
"default_idle_timeout_seconds",
|
|
141
|
+
"default_max_runtime_seconds",
|
|
142
|
+
"command_timeout_seconds",
|
|
143
|
+
"max_correction_attempts",
|
|
144
|
+
"env_allowlist",
|
|
145
|
+
"env_allowlist_extra",
|
|
146
|
+
"worktree_dir",
|
|
147
|
+
"loop_detection",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# What the loop detector (#194, ``autoforge.loop_detect``) does once an agent
|
|
151
|
+
# that keeps producing output repeats itself with nothing new: ``kill`` ends
|
|
152
|
+
# the invocation like a timeout, ``warn`` only says so in the progress output
|
|
153
|
+
# and ``execution.json``, ``off`` says nothing. Every mode records the
|
|
154
|
+
# calibration figures in ``provider_summary``. The default is ``kill`` (#199):
|
|
155
|
+
# real runs stayed below half of every default threshold (the margins are in
|
|
156
|
+
# docs/configuration.md, "How the defaults were set").
|
|
157
|
+
LOOP_DETECTION_MODES = ("kill", "warn", "off")
|
|
158
|
+
# The providers and phases no real run had recorded the calibration figures
|
|
159
|
+
# for when #199 made ``kill`` the default. Until they have, an invocation on
|
|
160
|
+
# one of them only warns unless ``mode`` is set (``mode: kill`` kills there
|
|
161
|
+
# too); see ``LoopDetectionConfig.for_invocation``.
|
|
162
|
+
LOOP_DETECTION_UNCALIBRATED_PROVIDERS = ("pi",)
|
|
163
|
+
LOOP_DETECTION_UNCALIBRATED_PHASES = ("UPDATE_EPIC",)
|
|
164
|
+
LOOP_DETECTION_KEYS = (
|
|
165
|
+
"mode",
|
|
166
|
+
"max_cycle_period",
|
|
167
|
+
"max_cycle_repeats",
|
|
168
|
+
"novelty_window_seconds",
|
|
169
|
+
"max_line_repeats",
|
|
170
|
+
)
|
|
171
|
+
# Upper bounds that keep the detector's per-action work and memory small: a
|
|
172
|
+
# cycle longer than this is not one an operator would call a loop, and a
|
|
173
|
+
# repeat count past the cap is no threshold at all.
|
|
174
|
+
MAX_LOOP_CYCLE_PERIOD = 16
|
|
175
|
+
MAX_LOOP_REPEATS = 100_000
|
|
176
|
+
|
|
177
|
+
# The environment an agent, a pre-merge verification command and a LOCAL
|
|
178
|
+
# validation command start from: these variables of the operator's
|
|
179
|
+
# environment and nothing else (see ``autoforge.executor.select_environment``).
|
|
180
|
+
# What a coding agent needs to find its tools, its home configuration, its
|
|
181
|
+
# locale and its temporary directory; what ``git`` needs to identify the
|
|
182
|
+
# author; and what an HTTPS client needs behind a corporate proxy or a
|
|
183
|
+
# private CA. No GitHub or git credential route is here, and none can be
|
|
184
|
+
# added (``autoforge.child_env.FORBIDDEN_ENV_NAMES``, #165): gh's tokens and
|
|
185
|
+
# configuration location, the SSH agent socket, git's ssh and askpass
|
|
186
|
+
# programs and its environment configuration are refused at load, and the
|
|
187
|
+
# controller sets the gh and git configuration a child sees itself, and its
|
|
188
|
+
# ``SHELL`` (a shell that reads no startup file of the operator's,
|
|
189
|
+
# ``autoforge.child_env.child_shell``). The API keys
|
|
190
|
+
# a provider's own CLI reads are contributed by the provider adapter
|
|
191
|
+
# (``AgentProvider.environment_names``), not listed here, so an OpenCode
|
|
192
|
+
# launch never carries an Anthropic key it has no use for. A trailing ``*``
|
|
193
|
+
# is a prefix. Anything else the operator's shell holds -- a cloud
|
|
194
|
+
# credential, a database password, another project's token -- is not
|
|
195
|
+
# inherited; ``env_allowlist_extra`` adds a name without restating this list.
|
|
196
|
+
DEFAULT_ENV_ALLOWLIST: tuple[str, ...] = (
|
|
197
|
+
"PATH",
|
|
198
|
+
"HOME",
|
|
199
|
+
"USER",
|
|
200
|
+
"LOGNAME",
|
|
201
|
+
"TERM",
|
|
202
|
+
"TMPDIR",
|
|
203
|
+
"TZ",
|
|
204
|
+
"LANG",
|
|
205
|
+
"LANGUAGE",
|
|
206
|
+
"LC_*",
|
|
207
|
+
"XDG_CONFIG_HOME",
|
|
208
|
+
"XDG_DATA_HOME",
|
|
209
|
+
"XDG_CACHE_HOME",
|
|
210
|
+
"XDG_STATE_HOME",
|
|
211
|
+
"GIT_AUTHOR_NAME",
|
|
212
|
+
"GIT_AUTHOR_EMAIL",
|
|
213
|
+
"GIT_COMMITTER_NAME",
|
|
214
|
+
"GIT_COMMITTER_EMAIL",
|
|
215
|
+
"HTTP_PROXY",
|
|
216
|
+
"HTTPS_PROXY",
|
|
217
|
+
"NO_PROXY",
|
|
218
|
+
"http_proxy",
|
|
219
|
+
"https_proxy",
|
|
220
|
+
"no_proxy",
|
|
221
|
+
"SSL_CERT_FILE",
|
|
222
|
+
"SSL_CERT_DIR",
|
|
223
|
+
"NODE_EXTRA_CA_CERTS",
|
|
224
|
+
"REQUESTS_CA_BUNDLE",
|
|
225
|
+
"CURL_CA_BUNDLE",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class LoopDetectionConfig:
|
|
231
|
+
"""``execution.loop_detection`` (#194): the mode and the thresholds.
|
|
232
|
+
|
|
233
|
+
A cycle of up to ``max_cycle_period`` tool calls (same name, input and
|
|
234
|
+
result) repeated ``max_cycle_repeats`` times in a row; no tool call the
|
|
235
|
+
invocation had not seen for ``novelty_window_seconds`` while at least
|
|
236
|
+
``max_cycle_period * max_cycle_repeats`` of them were replayed; a line of
|
|
237
|
+
unstructured output, or a cycle of up to ``max_cycle_period`` lines,
|
|
238
|
+
repeated ``max_line_repeats`` times in a row. See ``autoforge.loop_detect``.
|
|
239
|
+
|
|
240
|
+
``mode`` ``None`` is a config that does not set it: ``kill``, except on
|
|
241
|
+
an uncalibrated provider or phase, which only warns (#199). A launch runs
|
|
242
|
+
under :meth:`for_invocation`, which always names the mode.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
mode: str | None = None
|
|
246
|
+
max_cycle_period: int = 4
|
|
247
|
+
max_cycle_repeats: int = 8
|
|
248
|
+
novelty_window_seconds: int = 1800
|
|
249
|
+
max_line_repeats: int = 200
|
|
250
|
+
|
|
251
|
+
def for_invocation(self, provider: str, phase: str) -> LoopDetectionConfig:
|
|
252
|
+
"""This config with the mode one invocation of ``provider`` in
|
|
253
|
+
``phase`` runs under; a mode the config sets applies everywhere."""
|
|
254
|
+
if self.mode is not None:
|
|
255
|
+
return self
|
|
256
|
+
uncalibrated = (
|
|
257
|
+
provider in LOOP_DETECTION_UNCALIBRATED_PROVIDERS
|
|
258
|
+
or phase in LOOP_DETECTION_UNCALIBRATED_PHASES
|
|
259
|
+
)
|
|
260
|
+
return replace(self, mode="warn" if uncalibrated else "kill")
|
|
261
|
+
|
|
262
|
+
def describe(self) -> str:
|
|
263
|
+
"""The mode and thresholds, for the dry-run plan and ``doctor``."""
|
|
264
|
+
mode = self.mode or (
|
|
265
|
+
"kill (warn on provider "
|
|
266
|
+
+ ", ".join(LOOP_DETECTION_UNCALIBRATED_PROVIDERS)
|
|
267
|
+
+ " and in "
|
|
268
|
+
+ ", ".join(LOOP_DETECTION_UNCALIBRATED_PHASES)
|
|
269
|
+
+ ": not calibrated yet)"
|
|
270
|
+
)
|
|
271
|
+
return (
|
|
272
|
+
f"loop detection {mode} (cycles of up to {self.max_cycle_period} actions "
|
|
273
|
+
f"x{self.max_cycle_repeats}, no new action for {self.novelty_window_seconds}s, "
|
|
274
|
+
f"repeated lines x{self.max_line_repeats})"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def as_dict(self) -> dict[str, object]:
|
|
278
|
+
"""The same, for ``request.json`` and ``execution.json``."""
|
|
279
|
+
return {
|
|
280
|
+
"mode": self.mode,
|
|
281
|
+
"max_cycle_period": self.max_cycle_period,
|
|
282
|
+
"max_cycle_repeats": self.max_cycle_repeats,
|
|
283
|
+
"novelty_window_seconds": self.novelty_window_seconds,
|
|
284
|
+
"max_line_repeats": self.max_line_repeats,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class ExecutionConfig:
|
|
290
|
+
# The limits of a profile that sets none of its own (#193): an agent is
|
|
291
|
+
# killed once it has written nothing for ``default_idle_timeout_seconds``,
|
|
292
|
+
# and, when ``default_max_runtime_seconds`` is set, once it has run that
|
|
293
|
+
# long whatever it writes. ``None``: no wall-clock ceiling.
|
|
294
|
+
default_idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
|
|
295
|
+
default_max_runtime_seconds: int | None = None
|
|
296
|
+
# The wall-clock limit of a pre-merge verification command and of a
|
|
297
|
+
# LOCAL validation command; agents never use it.
|
|
298
|
+
command_timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS
|
|
299
|
+
# How many times a *malformed CONTROL_RESULT* (exit 0) triggers a
|
|
300
|
+
# correction prompt before the step fails. 0 disables correction.
|
|
301
|
+
max_correction_attempts: int = 1
|
|
302
|
+
# The environment agents and repository-defined commands start from
|
|
303
|
+
# (``env_allowlist`` replaces the default list; ``env_allowlist_extra``
|
|
304
|
+
# adds to whichever list is in force). See ``DEFAULT_ENV_ALLOWLIST``.
|
|
305
|
+
env_allowlist: list[str] = field(default_factory=lambda: list(DEFAULT_ENV_ALLOWLIST))
|
|
306
|
+
env_allowlist_extra: list[str] = field(default_factory=list)
|
|
307
|
+
# Where a REMOTE run's per-issue agent worktrees are created. Empty (the
|
|
308
|
+
# default): ``<git common dir>/autoforge/worktrees``, outside every
|
|
309
|
+
# working tree of the checkout; otherwise a directory, relative to the
|
|
310
|
+
# controller's working directory, that holds one worktree per issue.
|
|
311
|
+
worktree_dir: str = ""
|
|
312
|
+
# What an agent that keeps producing output but loops runs into (#194).
|
|
313
|
+
loop_detection: LoopDetectionConfig = field(default_factory=LoopDetectionConfig)
|
|
314
|
+
|
|
315
|
+
def environment_names(self) -> tuple[str, ...]:
|
|
316
|
+
"""The allow-list in force: the base list plus the additions, deduplicated."""
|
|
317
|
+
return tuple(dict.fromkeys([*self.env_allowlist, *self.env_allowlist_extra]))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# Paths whose contents define what the hosted checks actually run. See
|
|
321
|
+
# ``SafetyConfig.protected_merge_paths``.
|
|
322
|
+
DEFAULT_PROTECTED_MERGE_PATHS = (".github/workflows/",)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# The keys `safety:` may contain. Anything else is a hard error: a typo such
|
|
326
|
+
# as `allow_merges: true` must not fail closed *silently*, because the operator
|
|
327
|
+
# then believes the gate is in the state they wrote, not the state it is in.
|
|
328
|
+
# Every other section holds itself to the same rule (see `_section`): most of
|
|
329
|
+
# them carry loop bounds or merge behaviour, where a misspelled key keeps a
|
|
330
|
+
# looser built-in bound than the operator wrote.
|
|
331
|
+
SAFETY_KEYS = (
|
|
332
|
+
"acceptance_actions",
|
|
333
|
+
"allow_merge",
|
|
334
|
+
"protected_merge_paths",
|
|
335
|
+
"required_checks",
|
|
336
|
+
"verify_check_definition",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# `safety.required_checks` when no config file sets it: the aggregate check
|
|
340
|
+
# name of this repository's own `.github/workflows/ci.yml`.
|
|
341
|
+
DEFAULT_REQUIRED_CHECKS = ("ci",)
|
|
342
|
+
|
|
343
|
+
# `safety.allow_merge` when no config file sets it.
|
|
344
|
+
DEFAULT_ALLOW_MERGE_SOURCE = "built-in default"
|
|
345
|
+
|
|
346
|
+
# `safety.acceptance_actions` (#211): whether a REMOTE fixer may resolve a
|
|
347
|
+
# finding by asking the controller to publish an acceptance comment on the
|
|
348
|
+
# run's own issue, and who authorizes that one write. `off` refuses the
|
|
349
|
+
# resolution; `approve` (the default) publishes it only after the operator
|
|
350
|
+
# approves its action id (`autoforge unblock --approve-action`); `auto`
|
|
351
|
+
# publishes it during the FIX step with no human.
|
|
352
|
+
ACCEPTANCE_ACTIONS_OFF = "off"
|
|
353
|
+
ACCEPTANCE_ACTIONS_APPROVE = "approve"
|
|
354
|
+
ACCEPTANCE_ACTIONS_AUTO = "auto"
|
|
355
|
+
ACCEPTANCE_ACTION_POLICIES = (
|
|
356
|
+
ACCEPTANCE_ACTIONS_OFF,
|
|
357
|
+
ACCEPTANCE_ACTIONS_APPROVE,
|
|
358
|
+
ACCEPTANCE_ACTIONS_AUTO,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass
|
|
363
|
+
class SafetyConfig:
|
|
364
|
+
# Controller invariant: no real merge unless this is true AND the CLI
|
|
365
|
+
# passes --allow-merge. Default off for this milestone. This is the ONLY
|
|
366
|
+
# key that opens the gate: the historical `execution.allow_merge` is
|
|
367
|
+
# rejected on load rather than read, so two keys can never disagree about
|
|
368
|
+
# the most dangerous setting in the file.
|
|
369
|
+
allow_merge: bool = False
|
|
370
|
+
# Where `allow_merge` came from (`<config file>: safety.allow_merge`, or
|
|
371
|
+
# the built-in default) so `doctor` can show the effective gate state and
|
|
372
|
+
# the operator can find the line that set it.
|
|
373
|
+
allow_merge_source: str = DEFAULT_ALLOW_MERGE_SOURCE
|
|
374
|
+
# Paths a PR may not change and still be merged unattended. The hosted
|
|
375
|
+
# checks the merge gate trusts ("every check on the PR succeeded") are
|
|
376
|
+
# defined by the workflow files *in the PR itself*: GitHub runs the PR's
|
|
377
|
+
# version of `.github/workflows/` and reports the result under the same
|
|
378
|
+
# check name, so a PR that edits them also edits the meaning of its own
|
|
379
|
+
# green result. The controller cannot verify that from the outside, so
|
|
380
|
+
# it refuses to merge such a PR unattended and leaves it to a human.
|
|
381
|
+
# Entries ending in "/" are directory prefixes; anything else is an exact
|
|
382
|
+
# path or an fnmatch pattern (where "*" also spans "/"). An empty list
|
|
383
|
+
# disables the gate.
|
|
384
|
+
protected_merge_paths: list[str] = field(
|
|
385
|
+
default_factory=lambda: list(DEFAULT_PROTECTED_MERGE_PATHS)
|
|
386
|
+
)
|
|
387
|
+
# Status-check contexts the default branch is expected to *require*
|
|
388
|
+
# (repository ruleset or classic branch protection). The merge gate
|
|
389
|
+
# trusts "every check on the PR passed" and refuses a rollup with no
|
|
390
|
+
# check at all, or no successful one (#289), but which checks had to
|
|
391
|
+
# exist is a branch rule. `autoforge doctor` reads the effective branch
|
|
392
|
+
# rules and fails when one of these contexts is not required, or, with
|
|
393
|
+
# `allow_merge` true, when it cannot verify the rules (#289). An empty
|
|
394
|
+
# list only requires that *some* status check is required.
|
|
395
|
+
required_checks: list[str] = field(default_factory=lambda: list(DEFAULT_REQUIRED_CHECKS))
|
|
396
|
+
# Whether READY_FOR_MERGE / MERGE also verify *where* each required check
|
|
397
|
+
# came from. A green `ci` proves that the workflow defining it ran to
|
|
398
|
+
# completion on the PR; it does not prove that the workflow was the one
|
|
399
|
+
# the base branch defines. With this on, the controller resolves each
|
|
400
|
+
# `required_checks` context to the GitHub Actions run that produced it,
|
|
401
|
+
# requires that run to be at the reviewed HEAD, and requires its job and
|
|
402
|
+
# step structure (job names, step names in order) to equal that of the
|
|
403
|
+
# base branch's own most recent run of the same workflow at the base
|
|
404
|
+
# branch's current tip. A redefined, extended or trimmed workflow is a
|
|
405
|
+
# difference and BLOCKS the merge; so does a required context that is not
|
|
406
|
+
# an Actions check run, appears more than once, or has no base-branch run
|
|
407
|
+
# to compare against. This is a check on the *definition* that produced
|
|
408
|
+
# the green result, complementary to `protected_merge_paths` (which reads
|
|
409
|
+
# the PR's file list instead of GitHub's run record); neither proves what
|
|
410
|
+
# the tests in the PR assert (see `merge.verification_commands`).
|
|
411
|
+
verify_check_definition: bool = True
|
|
412
|
+
# Who may authorize the one external action a fixer can request, an
|
|
413
|
+
# acceptance comment on the run's own issue (#211): nobody (`off`), the
|
|
414
|
+
# operator per action id (`approve`) or this setting (`auto`). The
|
|
415
|
+
# value is recorded into the action when the FIX plan is saved, so a
|
|
416
|
+
# later change never widens an authorization already persisted.
|
|
417
|
+
acceptance_actions: str = ACCEPTANCE_ACTIONS_APPROVE
|
|
418
|
+
|
|
419
|
+
def protects(self, path: str) -> bool:
|
|
420
|
+
"""Whether ``path`` (a POSIX repo-relative path) is protected."""
|
|
421
|
+
for pattern in self.protected_merge_paths:
|
|
422
|
+
if pattern.endswith("/"):
|
|
423
|
+
if path == pattern[:-1] or path.startswith(pattern):
|
|
424
|
+
return True
|
|
425
|
+
elif path == pattern or fnmatch.fnmatchcase(path, pattern):
|
|
426
|
+
return True
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
GITHUB_KEYS = ("command", "timeout_seconds")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass
|
|
434
|
+
class GitHubConfig:
|
|
435
|
+
command: str = "gh"
|
|
436
|
+
timeout_seconds: int = 120
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
MERGE_METHODS = ("squash", "merge", "rebase")
|
|
440
|
+
|
|
441
|
+
MERGE_KEYS = ("method", "delete_branch", "max_verification_attempts", "verification_commands")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass
|
|
445
|
+
class MergeConfig:
|
|
446
|
+
"""How the *controller* merges once the gate is open (never an agent)."""
|
|
447
|
+
|
|
448
|
+
method: str = "squash" # squash | merge | rebase -> gh pr merge --<method>
|
|
449
|
+
# After a verified merge, delete the PR's head branch on GitHub only (a
|
|
450
|
+
# scoped REST call, never `gh pr merge --delete-branch`, #283).
|
|
451
|
+
delete_branch: bool = False
|
|
452
|
+
# Inconclusive GitHub data (mergeability UNKNOWN, checks still running,
|
|
453
|
+
# post-merge re-read failed) is re-checked on `resume` at most this many
|
|
454
|
+
# times per phase; then the run is BLOCKED instead of retrying forever.
|
|
455
|
+
max_verification_attempts: int = 5
|
|
456
|
+
# Controller-owned verification of the reviewed HEAD *before* the merge,
|
|
457
|
+
# on the operator's machine. The hosted check runs the PR's own code, so
|
|
458
|
+
# a PR that weakens what its tests assert produces a genuine green check
|
|
459
|
+
# without touching any protected path; nothing GitHub reports can tell
|
|
460
|
+
# that apart from a real pass. These argv arrays (never shell strings)
|
|
461
|
+
# are the controller's own evidence: once every GitHub-side check has
|
|
462
|
+
# passed, the exact reviewed commit is exported -- its committed bytes,
|
|
463
|
+
# proven against the commit's tree (`materialize.py`), into a private
|
|
464
|
+
# temporary directory, never the operator's checkout, never a worktree
|
|
465
|
+
# or branch -- and each command runs there in order, under
|
|
466
|
+
# `execution.command_timeout_seconds`. Any non-zero exit or timeout
|
|
467
|
+
# BLOCKS the merge for a human. A pass is
|
|
468
|
+
# persisted against the HEAD and the command list, so MERGE does not
|
|
469
|
+
# repeat it for the same commit. An empty list disables this
|
|
470
|
+
# verification: the commands are the project's own (`pytest`, `make
|
|
471
|
+
# check`, ...) and there is no build-system detection.
|
|
472
|
+
verification_commands: list[list[str]] = field(default_factory=list)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# The parallel EPIC scheduler's bounds (ADR 0008 D18.3, D18.4): no unbounded
|
|
476
|
+
# mode and no CPU-count default. ``scheduler.json`` (``SchedulerConfig``)
|
|
477
|
+
# enforces the same constants.
|
|
478
|
+
MAX_PARALLEL_BOUNDS = (1, 4)
|
|
479
|
+
POLL_INTERVAL_BOUNDS = (15, 3600)
|
|
480
|
+
STOP_GRACE_BOUNDS = (10, 600)
|
|
481
|
+
|
|
482
|
+
SCHEDULER_KEYS = ("max_parallel", "poll_interval_seconds", "stop_grace_seconds")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@dataclass
|
|
486
|
+
class EpicSchedulerConfig:
|
|
487
|
+
"""How ``autoforge epic`` runs one EPIC's issues in parallel (ADR 0008 D18; #182).
|
|
488
|
+
|
|
489
|
+
``max_parallel`` is the number of issue workers live at once (1-4).
|
|
490
|
+
``poll_interval_seconds`` is the least time between two EPIC snapshots
|
|
491
|
+
while admission is open. ``stop_grace_seconds`` is how long a stop or a
|
|
492
|
+
first Ctrl-C waits for the workers to return after it interrupted them:
|
|
493
|
+
above the executor's agent teardown plus a state save. Workers never
|
|
494
|
+
merge, whatever ``safety.allow_merge`` says.
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
max_parallel: int = 2
|
|
498
|
+
poll_interval_seconds: int = 60
|
|
499
|
+
stop_grace_seconds: int = 30
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
VALIDATION_KEYS = ("commands", "scope")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@dataclass
|
|
506
|
+
class ValidationConfig:
|
|
507
|
+
"""REMOTE candidate validation (``validation:`` in the config file; ADR 0006 D3.1).
|
|
508
|
+
|
|
509
|
+
``commands`` are trusted argv arrays (never shell strings), empty by
|
|
510
|
+
default: the controller runs each one, in order, in a byte-exact export
|
|
511
|
+
of one exact candidate commit, under ``execution.command_timeout_seconds``
|
|
512
|
+
and ``execution.env_allowlist``, and records what it observed in a
|
|
513
|
+
``ValidationRecord`` in the state file. They are read from this
|
|
514
|
+
configuration only, never from the candidate tree, an agent result or
|
|
515
|
+
issue text. At most 16 definitions; an argv holds at most 32 elements,
|
|
516
|
+
each non-blank and at most 1,024 characters, and 4,096 in all (D10.3).
|
|
517
|
+
|
|
518
|
+
``scope`` is ``candidate-tree``, the only scope there is (D4.2): a
|
|
519
|
+
definition depends on the candidate's bytes alone. Any other value is
|
|
520
|
+
refused.
|
|
521
|
+
|
|
522
|
+
Separate from ``merge.verification_commands``, which keeps running at
|
|
523
|
+
every merge gate pass; the same commands may be configured in both.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
commands: list[list[str]] = field(default_factory=list)
|
|
527
|
+
scope: str = SCOPE
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
REPLAN_KEYS = (
|
|
531
|
+
"enabled",
|
|
532
|
+
"soft_threshold",
|
|
533
|
+
"hard_threshold",
|
|
534
|
+
"stagnation_window",
|
|
535
|
+
"max_findings_per_round",
|
|
536
|
+
"max_replans_per_issue",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@dataclass
|
|
541
|
+
class ReplanConfig:
|
|
542
|
+
"""Controller policy for abandoning a non-converging implementation."""
|
|
543
|
+
|
|
544
|
+
enabled: bool = True
|
|
545
|
+
# First review round from which the controller may *replace* an
|
|
546
|
+
# implementation instead of continuing to patch it. Every stagnation
|
|
547
|
+
# trigger is gated behind it, including `workflow.stagnation_*`: below
|
|
548
|
+
# this round a stagnant loop still ends in BLOCKED for a human, which is
|
|
549
|
+
# far less destructive than discarding a PR after one ineffective FIX.
|
|
550
|
+
soft_threshold: int = 12
|
|
551
|
+
# Review round at which findings trigger a replan unconditionally.
|
|
552
|
+
hard_threshold: int = 20
|
|
553
|
+
# Window rule, from `soft_threshold`: this many trailing review rounds
|
|
554
|
+
# with findings, each holding at most `max_findings_per_round`, trigger a
|
|
555
|
+
# replan. It deliberately counts rounds of entirely new findings too --
|
|
556
|
+
# recurrence is what `workflow.stagnation_*` detects, and that verdict is
|
|
557
|
+
# already a replan trigger from the same round; this rule exists for the
|
|
558
|
+
# long tail of small, fresh findings that never ends.
|
|
559
|
+
stagnation_window: int = 3
|
|
560
|
+
max_findings_per_round: int = 2
|
|
561
|
+
# Counts only completed REPLAN_REEXECUTE lifecycles; initial implementation
|
|
562
|
+
# is not a replan.
|
|
563
|
+
max_replans_per_issue: int = 2
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
REVIEW_KEYS = ("replan",)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@dataclass
|
|
570
|
+
class ReviewConfig:
|
|
571
|
+
replan: ReplanConfig = field(default_factory=ReplanConfig)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
WORKFLOW_KEYS = (
|
|
575
|
+
"max_review_rounds",
|
|
576
|
+
"stagnation_identical_rounds",
|
|
577
|
+
"stagnation_unchanged_count_rounds",
|
|
578
|
+
"max_total_steps",
|
|
579
|
+
"epic_update_every",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@dataclass
|
|
584
|
+
class WorkflowConfig:
|
|
585
|
+
"""Controller-owned bounds on the workflow loop (see ``loop_guard.py``).
|
|
586
|
+
|
|
587
|
+
Every bound is enforced from persisted state, so ``resume`` continues
|
|
588
|
+
the same budget instead of starting a fresh one. Reaching a bound enters
|
|
589
|
+
BLOCKED with an explicit reason; nothing is merged, pushed or retried.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
# Review rounds a single PR may consume. Round N with findings, where
|
|
593
|
+
# N == max_review_rounds, is BLOCKED instead of starting another FIX
|
|
594
|
+
# (a FIX whose result could never be reviewed is never invoked); a
|
|
595
|
+
# clean round N still reaches READY_FOR_MERGE. Round N+1 never starts.
|
|
596
|
+
max_review_rounds: int = 20
|
|
597
|
+
# Consecutive review rounds with findings whose required resolutions are
|
|
598
|
+
# identical (normalised text) before the loop is declared stagnant.
|
|
599
|
+
# 0 disables this rule (the hard cap above still applies); 1 is rejected
|
|
600
|
+
# because the rule compares rounds against each other.
|
|
601
|
+
stagnation_identical_rounds: int = 2
|
|
602
|
+
# Consecutive review rounds with findings whose finding *count* never
|
|
603
|
+
# changed, while at least one required resolution recurs within those
|
|
604
|
+
# rounds (A/B/A ping-pong), before the loop is declared stagnant. Rounds
|
|
605
|
+
# of entirely new findings are progress and only meet the cap above.
|
|
606
|
+
# 0 disables this rule; 1 is rejected (a one-round window can hold no
|
|
607
|
+
# recurrence, so it would disable the rule while looking enabled).
|
|
608
|
+
stagnation_unchanged_count_rounds: int = 3
|
|
609
|
+
# Cumulative executed steps for the whole run (all issues, all phases,
|
|
610
|
+
# across `resume`). Persisted as ``step_count``; the CLI's ``--max-steps``
|
|
611
|
+
# is only a per-invocation slice of this budget.
|
|
612
|
+
max_total_steps: int = 300
|
|
613
|
+
# Merged PRs per EPIC roadmap update. UPDATE_EPIC still runs after every
|
|
614
|
+
# MERGE (it is where the next issue is chosen), but the controller only
|
|
615
|
+
# asks for, writes and reads back the EPIC's managed roadmap section, and
|
|
616
|
+
# resets ``merged_since_epic_update``, once that many merges have
|
|
617
|
+
# accumulated since the last verified write. 1 updates after every merge.
|
|
618
|
+
epic_update_every: int = 1
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
LOCAL_KEYS = (
|
|
622
|
+
"feature_dir",
|
|
623
|
+
"validation_commands",
|
|
624
|
+
"max_fix_rounds",
|
|
625
|
+
"exclude",
|
|
626
|
+
"max_workspace_entries",
|
|
627
|
+
"max_workspace_bytes",
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@dataclass
|
|
632
|
+
class LocalConfig:
|
|
633
|
+
"""LOCAL-mode settings (``local:`` in the config file).
|
|
634
|
+
|
|
635
|
+
``validation_commands`` are **controller-owned** checks: argv arrays, run
|
|
636
|
+
through :mod:`autoforge.executor` with no shell, after every local
|
|
637
|
+
implementation and fix phase. A non-zero exit means the phase is not
|
|
638
|
+
successfully verified. There is no automatic build-system detection: what
|
|
639
|
+
is not configured here is not run.
|
|
640
|
+
|
|
641
|
+
``exclude`` is the *only* way to remove anything from the workspace
|
|
642
|
+
snapshot that binds a LOCAL review (the repository's own git directory
|
|
643
|
+
aside, which is identified by inode). A LOCAL run hashes every entry of
|
|
644
|
+
the working tree, ignored files included, because the alternative is
|
|
645
|
+
letting a ``.gitignore`` edit decide which bytes a review covers. That
|
|
646
|
+
completeness has a cost the operator has to pay explicitly: build output,
|
|
647
|
+
virtual environments and caches are rewritten by the very validation
|
|
648
|
+
commands the controller runs, so they must be declared unreviewed rather
|
|
649
|
+
than silently tolerated. Each pattern is matched component-wise against
|
|
650
|
+
repository-relative paths; ``*`` and ``?`` match within one component and
|
|
651
|
+
``**`` spans any number of them. The patterns are hashed into the
|
|
652
|
+
fingerprint and named to the review agent in its prompt, so "what was not
|
|
653
|
+
reviewed" is part of the review's identity rather than a local detail.
|
|
654
|
+
|
|
655
|
+
``max_workspace_entries`` / ``max_workspace_bytes`` bound the walk. They
|
|
656
|
+
are refusal thresholds, not sampling thresholds: exceeding one fails the
|
|
657
|
+
run with the largest subtrees named, because a fingerprint that fell back
|
|
658
|
+
to metadata for the rest would accept an equal-sized replacement with a
|
|
659
|
+
restored mtime.
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
# Where ``autoforge local init`` writes feature specifications. Project
|
|
663
|
+
# data, never under the runtime state directory.
|
|
664
|
+
feature_dir: str = "features"
|
|
665
|
+
validation_commands: list[list[str]] = field(default_factory=list)
|
|
666
|
+
# Local review/fix bound: the initial REVIEW, at most this many FIX
|
|
667
|
+
# rounds, then a final REVIEW. 0 disables FIX entirely (one review pass).
|
|
668
|
+
max_fix_rounds: int = 1
|
|
669
|
+
# Declared-unreviewed regions of the working tree (see the class docstring).
|
|
670
|
+
exclude: list[str] = field(default_factory=list)
|
|
671
|
+
max_workspace_entries: int = DEFAULT_MAX_ENTRIES
|
|
672
|
+
max_workspace_bytes: int = DEFAULT_MAX_BYTES
|
|
673
|
+
|
|
674
|
+
@property
|
|
675
|
+
def max_review_rounds(self) -> int:
|
|
676
|
+
"""Review passes a local run may complete (fix rounds + the first)."""
|
|
677
|
+
return self.max_fix_rounds + 1
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
SANDBOX_DISABLED = "disabled"
|
|
681
|
+
SANDBOX_PREFERRED = "preferred"
|
|
682
|
+
SANDBOX_REQUIRED = "required"
|
|
683
|
+
SANDBOX_MODES = (SANDBOX_DISABLED, SANDBOX_PREFERRED, SANDBOX_REQUIRED)
|
|
684
|
+
SANDBOX_NETWORKS = ("host", "isolated")
|
|
685
|
+
SANDBOX_KEYS = ("mode", "validation_network", "premerge_network", "read_only_paths")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@dataclass
|
|
689
|
+
class SandboxConfig:
|
|
690
|
+
"""The outer sandbox (``sandbox:`` in the config file; ADR 0005 D12, D9.2, D15.4).
|
|
691
|
+
|
|
692
|
+
``mode`` is one of:
|
|
693
|
+
|
|
694
|
+
- ``disabled`` (the default): logical isolation only (#165); no launch
|
|
695
|
+
is sandboxed, and ``doctor``, the plan and ``status`` say so;
|
|
696
|
+
- ``required``: every agent launch, LOCAL validation command and
|
|
697
|
+
pre-merge verification command runs inside the outer sandbox, or is
|
|
698
|
+
refused; it never runs without it. A host that cannot enforce is
|
|
699
|
+
refused by the pre-launch gate, before anything is saved or charged;
|
|
700
|
+
- ``preferred``: in a LOCAL run, enforced when the host can, else run
|
|
701
|
+
with logical isolation only, visibly (the plan, ``status``, stderr at
|
|
702
|
+
the launch, every execution record); in a REMOTE run, exactly
|
|
703
|
+
``required``, since nobody watches an unattended fallback (D12.2).
|
|
704
|
+
|
|
705
|
+
Not part of LOCAL's run contract (D12.3): it decides how a launch is
|
|
706
|
+
contained, not what the run is. A run is pinned to the mode it is created
|
|
707
|
+
under instead (#280, :mod:`autoforge.sandbox_pin`): a later command under
|
|
708
|
+
a stronger mode moves the pin up, and one under a weaker mode is refused
|
|
709
|
+
unless the operator accepts it with ``--accept-config-change``. Under
|
|
710
|
+
``required`` or ``preferred`` the ``--config`` file must lie outside every
|
|
711
|
+
tree the repository's enforced launches write and every directory a
|
|
712
|
+
provider's launches are given read-write, or every command refuses it
|
|
713
|
+
(:func:`autoforge.engine.config_file_placement_problem`). The candidate
|
|
714
|
+
read and the strict import of an issue's private objects (D6.3-D6.5)
|
|
715
|
+
apply in every mode.
|
|
716
|
+
|
|
717
|
+
``validation_network`` and ``premerge_network`` are ``host`` (the
|
|
718
|
+
default) or ``isolated``: a validation command (LOCAL, or REMOTE
|
|
719
|
+
``validation.commands`` in a candidate's export), or a pre-merge
|
|
720
|
+
verification command, then runs with no network at all (no loopback, no
|
|
721
|
+
abstract socket, no download). Agents always share the host network.
|
|
722
|
+
|
|
723
|
+
``read_only_paths`` are absolute host paths every sandboxed launch sees
|
|
724
|
+
read-only (D15.4): a toolchain, an interpreter or a file a build needs
|
|
725
|
+
outside ``/usr`` and ``/etc``. Refused at load when one names the
|
|
726
|
+
operator's home, an ancestor of it or a forbidden source, or two nest;
|
|
727
|
+
one overlapping a denied root of the checkout is refused before the
|
|
728
|
+
first launch. There are no operator-declared writable paths.
|
|
729
|
+
|
|
730
|
+
The networks and the paths are read only when a launch is enforced.
|
|
731
|
+
"""
|
|
732
|
+
|
|
733
|
+
mode: str = SANDBOX_DISABLED
|
|
734
|
+
validation_network: str = "host"
|
|
735
|
+
premerge_network: str = "host"
|
|
736
|
+
read_only_paths: list[str] = field(default_factory=list)
|
|
737
|
+
|
|
738
|
+
@property
|
|
739
|
+
def enabled(self) -> bool:
|
|
740
|
+
"""A sandbox is wanted: ``required`` or ``preferred``."""
|
|
741
|
+
return self.mode != SANDBOX_DISABLED
|
|
742
|
+
|
|
743
|
+
def refuses_unenforced(self, local: bool) -> bool:
|
|
744
|
+
"""Whether a launch the host cannot enforce is refused rather than run.
|
|
745
|
+
|
|
746
|
+
``required`` everywhere, ``preferred`` in REMOTE (ADR 0005 D12.2).
|
|
747
|
+
"""
|
|
748
|
+
return self.mode == SANDBOX_REQUIRED or (self.mode == SANDBOX_PREFERRED and not local)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# The keys a config file may contain at top level: the scalars read directly
|
|
752
|
+
# below plus one entry per section. There is no `repository` key: the
|
|
753
|
+
# repository comes from the issue URLs on the command line.
|
|
754
|
+
TOP_LEVEL_KEYS = (
|
|
755
|
+
"version",
|
|
756
|
+
"state_dir",
|
|
757
|
+
"prompt_version",
|
|
758
|
+
"execution",
|
|
759
|
+
"safety",
|
|
760
|
+
"github",
|
|
761
|
+
"merge",
|
|
762
|
+
"review",
|
|
763
|
+
"workflow",
|
|
764
|
+
"local",
|
|
765
|
+
"sandbox",
|
|
766
|
+
"validation",
|
|
767
|
+
"scheduler",
|
|
768
|
+
"profiles",
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@dataclass(frozen=True)
|
|
773
|
+
class AgentLimits:
|
|
774
|
+
"""The limits one agent invocation runs under (#193).
|
|
775
|
+
|
|
776
|
+
``idle_timeout_seconds``: the agent is killed once it has written
|
|
777
|
+
nothing on stdout or stderr for that long; ``None`` when its CLI writes
|
|
778
|
+
nothing until it exits, so only the ceiling can apply.
|
|
779
|
+
``max_runtime_seconds``: the wall-clock ceiling from the launch;
|
|
780
|
+
``None``: unset, so only the executor's one-week backstop applies.
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
idle_timeout_seconds: int | None
|
|
784
|
+
max_runtime_seconds: int | None
|
|
785
|
+
|
|
786
|
+
def describe(self) -> str:
|
|
787
|
+
"""Both values, for the dry-run plan, ``doctor`` and the launch line."""
|
|
788
|
+
idle = (
|
|
789
|
+
"off (no output until exit)"
|
|
790
|
+
if self.idle_timeout_seconds is None
|
|
791
|
+
else f"{self.idle_timeout_seconds}s"
|
|
792
|
+
)
|
|
793
|
+
ceiling = "unset" if self.max_runtime_seconds is None else f"{self.max_runtime_seconds}s"
|
|
794
|
+
return f"idle timeout {idle}, max runtime {ceiling}"
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@dataclass
|
|
798
|
+
class AutoForgeConfig:
|
|
799
|
+
version: int = CONFIG_VERSION
|
|
800
|
+
state_dir: str = DEFAULT_STATE_DIR
|
|
801
|
+
prompt_version: str = __prompt_version__
|
|
802
|
+
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
|
|
803
|
+
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
804
|
+
github: GitHubConfig = field(default_factory=GitHubConfig)
|
|
805
|
+
merge: MergeConfig = field(default_factory=MergeConfig)
|
|
806
|
+
review: ReviewConfig = field(default_factory=ReviewConfig)
|
|
807
|
+
workflow: WorkflowConfig = field(default_factory=WorkflowConfig)
|
|
808
|
+
local: LocalConfig = field(default_factory=LocalConfig)
|
|
809
|
+
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
|
|
810
|
+
validation: ValidationConfig = field(default_factory=ValidationConfig)
|
|
811
|
+
scheduler: EpicSchedulerConfig = field(default_factory=EpicSchedulerConfig)
|
|
812
|
+
profiles: dict[str, ProfileConfig] = field(default_factory=dict)
|
|
813
|
+
|
|
814
|
+
def profile(self, name: str) -> ProfileConfig:
|
|
815
|
+
try:
|
|
816
|
+
return self.profiles[name]
|
|
817
|
+
except KeyError:
|
|
818
|
+
raise ConfigurationError(f"unknown execution profile {name!r}") from None
|
|
819
|
+
|
|
820
|
+
def agent_limits(self, profile: ProfileConfig) -> AgentLimits:
|
|
821
|
+
"""The limits ``profile``'s agent runs under, or ConfigurationError.
|
|
822
|
+
|
|
823
|
+
The profile's own values, else the ``execution.default_*`` ones. A
|
|
824
|
+
CLI that writes nothing until it exits (Claude with ``output_format:
|
|
825
|
+
text``) would look frozen for its whole run, so it cannot run under
|
|
826
|
+
an idle limit: it runs under its wall-clock ceiling alone, and a
|
|
827
|
+
ceiling is then required, never an unbounded run. A profile whose
|
|
828
|
+
``interaction_policy`` is ``prompt`` needs a ``max_wait_seconds``
|
|
829
|
+
below both limits (ADR 0007 D7.3).
|
|
830
|
+
"""
|
|
831
|
+
from .providers import provider_for # local import: providers depends on config
|
|
832
|
+
|
|
833
|
+
for key in ("idle_timeout_seconds", "max_runtime_seconds"):
|
|
834
|
+
value = getattr(profile, key)
|
|
835
|
+
if value is not None:
|
|
836
|
+
_check_limit(value, f"profile {profile.name!r}", key)
|
|
837
|
+
idle = (
|
|
838
|
+
profile.idle_timeout_seconds
|
|
839
|
+
if profile.idle_timeout_seconds is not None
|
|
840
|
+
else self.execution.default_idle_timeout_seconds
|
|
841
|
+
)
|
|
842
|
+
ceiling = (
|
|
843
|
+
profile.max_runtime_seconds
|
|
844
|
+
if profile.max_runtime_seconds is not None
|
|
845
|
+
else self.execution.default_max_runtime_seconds
|
|
846
|
+
)
|
|
847
|
+
provider = provider_for(profile)
|
|
848
|
+
settings = provider.interaction_settings(profile)
|
|
849
|
+
if settings is not None and settings.live:
|
|
850
|
+
# A live ask waits for a person: the wait must end before either
|
|
851
|
+
# limit would end the invocation under it (ADR 0007 D7.3).
|
|
852
|
+
wait = settings.max_wait_seconds
|
|
853
|
+
for key, limit in (("idle timeout", idle), ("max_runtime_seconds", ceiling)):
|
|
854
|
+
if limit is not None and wait >= limit:
|
|
855
|
+
raise ConfigurationError(
|
|
856
|
+
f"profile {profile.name!r}: option max_wait_seconds ({wait}) must be "
|
|
857
|
+
f"below its {key} ({limit}s): a wait for an answer that outlasts the "
|
|
858
|
+
"limit would end the invocation under the person answering"
|
|
859
|
+
)
|
|
860
|
+
if provider.reports_activity(profile):
|
|
861
|
+
return AgentLimits(idle_timeout_seconds=idle, max_runtime_seconds=ceiling)
|
|
862
|
+
if ceiling is None:
|
|
863
|
+
raise ConfigurationError(
|
|
864
|
+
f"profile {profile.name!r}: its {profile.provider} CLI writes nothing until it "
|
|
865
|
+
"exits with these options, so it cannot run under an idle timeout and needs a "
|
|
866
|
+
"wall-clock limit: set 'max_runtime_seconds' on the profile (or "
|
|
867
|
+
"'execution.default_max_runtime_seconds'), or use an output format that "
|
|
868
|
+
"reports progress (claude: output_format stream-json)"
|
|
869
|
+
)
|
|
870
|
+
return AgentLimits(idle_timeout_seconds=None, max_runtime_seconds=ceiling)
|
|
871
|
+
|
|
872
|
+
@property
|
|
873
|
+
def merge_allowed_by_config(self) -> bool:
|
|
874
|
+
"""The config half of the merge gate: `safety.allow_merge` and nothing else."""
|
|
875
|
+
return bool(self.safety.allow_merge)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _claude_profile(name: str) -> ProfileConfig:
|
|
879
|
+
return ProfileConfig(
|
|
880
|
+
name=name,
|
|
881
|
+
provider="claude",
|
|
882
|
+
model="fable",
|
|
883
|
+
effort="high",
|
|
884
|
+
command="claude",
|
|
885
|
+
extra_args=[],
|
|
886
|
+
options={"permission_mode": "bypassPermissions", "output_format": "stream-json"},
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _opencode_profile(name: str, model: str, effort: str) -> ProfileConfig:
|
|
891
|
+
return ProfileConfig(
|
|
892
|
+
name=name,
|
|
893
|
+
provider="opencode",
|
|
894
|
+
model=model,
|
|
895
|
+
effort=effort,
|
|
896
|
+
command="opencode",
|
|
897
|
+
extra_args=[],
|
|
898
|
+
options={"output_format": "default", "auto_approve": "false"},
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def default_config() -> AutoForgeConfig:
|
|
903
|
+
"""Built-in defaults mirroring autoforge.example.yaml."""
|
|
904
|
+
profiles = {
|
|
905
|
+
"analyze_execute": _claude_profile("analyze_execute"),
|
|
906
|
+
"fix": _claude_profile("fix"),
|
|
907
|
+
"review_round_1": _opencode_profile("review_round_1", "openai/gpt-5.6-luna", "high"),
|
|
908
|
+
"review_round_2_5": _opencode_profile("review_round_2_5", "openai/gpt-5.6-terra", "high"),
|
|
909
|
+
"review_round_6_plus": _opencode_profile(
|
|
910
|
+
"review_round_6_plus", "openai/gpt-5.6-sol", "medium"
|
|
911
|
+
),
|
|
912
|
+
"replan_reexecute": _opencode_profile("replan_reexecute", "openai/gpt-5.6-terra", "high"),
|
|
913
|
+
"update_epic": _opencode_profile("update_epic", "openai/gpt-5.6-sol", "high"),
|
|
914
|
+
}
|
|
915
|
+
return AutoForgeConfig(profiles=profiles)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# -- validation ------------------------------------------------------------
|
|
919
|
+
def validate_profile(profile: ProfileConfig) -> None:
|
|
920
|
+
"""Raise ConfigurationError when a profile cannot possibly be executed."""
|
|
921
|
+
if profile.provider not in KNOWN_PROVIDERS:
|
|
922
|
+
raise ConfigurationError(
|
|
923
|
+
f"profile {profile.name!r}: unknown provider {profile.provider!r} "
|
|
924
|
+
f"(expected one of {KNOWN_PROVIDERS})"
|
|
925
|
+
)
|
|
926
|
+
if profile.provider != "scripted" and not profile.model:
|
|
927
|
+
raise ConfigurationError(f"profile {profile.name!r}: 'model' must be set")
|
|
928
|
+
from .providers import provider_for
|
|
929
|
+
|
|
930
|
+
provider_for(profile).validate_profile(profile)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def validate_required_profiles(cfg: AutoForgeConfig, names: list[str]) -> None:
|
|
934
|
+
"""Fail early (ConfigurationError) if any required profile is missing/invalid."""
|
|
935
|
+
missing = [n for n in names if n not in cfg.profiles]
|
|
936
|
+
if missing:
|
|
937
|
+
raise ConfigurationError(
|
|
938
|
+
f"required execution profile(s) not configured: {', '.join(missing)} "
|
|
939
|
+
"— add them under 'profiles:' in the AutoForge config"
|
|
940
|
+
)
|
|
941
|
+
for n in names:
|
|
942
|
+
validate_profile(cfg.profiles[n])
|
|
943
|
+
cfg.agent_limits(cfg.profiles[n])
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _check_limit(value: int, where: str, key: str) -> None:
|
|
947
|
+
"""A limit in seconds is > 0 and within the executor's backstop."""
|
|
948
|
+
if not 0 < value <= MAX_DEADLINE_SECONDS:
|
|
949
|
+
raise ConfigurationError(
|
|
950
|
+
f"{where}: {key!r} must be > 0 and <= {MAX_DEADLINE_SECONDS} (one week), "
|
|
951
|
+
f"got {value} (a non-positive value would disable the limit entirely)"
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
# -- file loading ----------------------------------------------------------
|
|
956
|
+
def load_config_file(path: str | Path | None) -> AutoForgeConfig:
|
|
957
|
+
"""Load config from file, or defaults when path is None.
|
|
958
|
+
|
|
959
|
+
Raises ConfigurationError on any problem.
|
|
960
|
+
"""
|
|
961
|
+
cfg = default_config()
|
|
962
|
+
if path is None:
|
|
963
|
+
return cfg
|
|
964
|
+
p = Path(path)
|
|
965
|
+
if not p.exists():
|
|
966
|
+
raise ConfigurationError(f"config file not found: {p}")
|
|
967
|
+
suffix = p.suffix.lower()
|
|
968
|
+
data: object
|
|
969
|
+
try:
|
|
970
|
+
if suffix == ".toml":
|
|
971
|
+
import tomllib
|
|
972
|
+
|
|
973
|
+
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
974
|
+
elif suffix == ".json":
|
|
975
|
+
data = json.loads(
|
|
976
|
+
p.read_text(encoding="utf-8"), object_pairs_hook=_pairs_without_duplicates
|
|
977
|
+
)
|
|
978
|
+
elif suffix in (".yaml", ".yml"):
|
|
979
|
+
data = _load_yaml(p)
|
|
980
|
+
else:
|
|
981
|
+
raise ConfigurationError(
|
|
982
|
+
f"unsupported config extension {suffix!r} (use .yaml/.yml/.toml/.json)"
|
|
983
|
+
)
|
|
984
|
+
except ConfigurationError:
|
|
985
|
+
raise
|
|
986
|
+
except Exception as exc: # parse errors -> ConfigurationError
|
|
987
|
+
raise ConfigurationError(f"cannot parse config {p}: {exc}") from exc
|
|
988
|
+
if not isinstance(data, dict):
|
|
989
|
+
raise ConfigurationError(f"config {p} must contain a mapping at top level")
|
|
990
|
+
return _merge_config(cfg, data, source=str(p))
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def config_digest(cfg: AutoForgeConfig) -> str:
|
|
994
|
+
"""The SHA-256 of the effective configuration, as canonical JSON (ADR 0008 D9.2 step 4).
|
|
995
|
+
|
|
996
|
+
A scheduler records it in ``scheduler.json`` and each worker requires
|
|
997
|
+
the configuration it loaded to have the same one, so a file edited
|
|
998
|
+
between the scheduler's start and a worker's is refused, never run.
|
|
999
|
+
It covers every field of the effective configuration, the defaults a
|
|
1000
|
+
file left in force included; it is a comparison, never shown.
|
|
1001
|
+
"""
|
|
1002
|
+
document = json.dumps(asdict(cfg), sort_keys=True, separators=(",", ":"), default=str)
|
|
1003
|
+
return hashlib.sha256(document.encode("utf-8")).hexdigest()
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# Every parser backend must hand `_merge_config` the document as written: a
|
|
1007
|
+
# key the operator wrote and the loader never saw is the silent no-op that
|
|
1008
|
+
# `_reject_unknown_keys` exists to refuse, and a duplicate key is exactly
|
|
1009
|
+
# that -- `json.loads`, PyYAML and the subset parser all keep the last value
|
|
1010
|
+
# and drop the earlier one, typo included, before any validation runs. TOML
|
|
1011
|
+
# (`tomllib`) rejects a duplicate on its own; the other three are told to.
|
|
1012
|
+
def _pairs_without_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
1013
|
+
"""``json.loads`` object hook: a repeated key is a parse error, not a merge."""
|
|
1014
|
+
out: dict[str, object] = {}
|
|
1015
|
+
for key, value in pairs:
|
|
1016
|
+
if key in out:
|
|
1017
|
+
raise ValueError(f"duplicate key {key!r}")
|
|
1018
|
+
out[key] = value
|
|
1019
|
+
return out
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _as_options(raw: object, source: str, name: str) -> dict[str, str]:
|
|
1023
|
+
if raw is None:
|
|
1024
|
+
return {}
|
|
1025
|
+
if not isinstance(raw, dict):
|
|
1026
|
+
raise ConfigurationError(f"{source}: profile {name!r} 'options' must be a mapping")
|
|
1027
|
+
out: dict[str, str] = {}
|
|
1028
|
+
for k, v in raw.items():
|
|
1029
|
+
if isinstance(v, bool):
|
|
1030
|
+
out[str(k)] = "true" if v else "false"
|
|
1031
|
+
else:
|
|
1032
|
+
out[str(k)] = "" if v is None else str(v)
|
|
1033
|
+
return out
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _as_str_list(raw: object, source: str, key: str) -> list[str]:
|
|
1037
|
+
"""A list of non-empty strings -- never a bare string silently split.
|
|
1038
|
+
|
|
1039
|
+
An explicit ``null`` is rejected rather than read as ``[]``. The empty
|
|
1040
|
+
list is a deliberate opt-out (for ``safety.protected_merge_paths`` it
|
|
1041
|
+
turns the merge gate off), and a key whose value went missing -- a
|
|
1042
|
+
hand-edited config, a generator emitting nothing, a list the YAML subset
|
|
1043
|
+
parser could not read -- must not be able to disable a safety gate by
|
|
1044
|
+
looking like one.
|
|
1045
|
+
"""
|
|
1046
|
+
if raw is None:
|
|
1047
|
+
raise ConfigurationError(
|
|
1048
|
+
f"{source}: {key} is null; write [] to set it empty deliberately, "
|
|
1049
|
+
"or remove the key to keep the default"
|
|
1050
|
+
)
|
|
1051
|
+
if isinstance(raw, str) or not isinstance(raw, list):
|
|
1052
|
+
raise ConfigurationError(f"{source}: {key} must be a list of strings, got {raw!r}")
|
|
1053
|
+
out: list[str] = []
|
|
1054
|
+
for item in raw:
|
|
1055
|
+
if not isinstance(item, str) or not item.strip():
|
|
1056
|
+
raise ConfigurationError(
|
|
1057
|
+
f"{source}: {key} must contain non-empty strings, got {item!r}"
|
|
1058
|
+
)
|
|
1059
|
+
out.append(item.strip())
|
|
1060
|
+
return out
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _as_argv_list(raw: object, source: str, key: str) -> list[list[str]]:
|
|
1064
|
+
"""A list of argv arrays -- never a shell string.
|
|
1065
|
+
|
|
1066
|
+
``[["./gradlew", "test"]]`` is accepted; ``["./gradlew test"]`` is not.
|
|
1067
|
+
A command line is not parsed, split or handed to a shell anywhere in
|
|
1068
|
+
AutoForge, so accepting a string here would create the one place where a
|
|
1069
|
+
quoting bug turns into arbitrary command execution.
|
|
1070
|
+
"""
|
|
1071
|
+
if raw is None:
|
|
1072
|
+
raise ConfigurationError(
|
|
1073
|
+
f"{source}: {key} is null; write [] to set it empty deliberately, "
|
|
1074
|
+
"or remove the key to keep the default"
|
|
1075
|
+
)
|
|
1076
|
+
if isinstance(raw, str) or not isinstance(raw, list):
|
|
1077
|
+
raise ConfigurationError(f"{source}: {key} must be a list of argv arrays, got {raw!r}")
|
|
1078
|
+
out: list[list[str]] = []
|
|
1079
|
+
for index, entry in enumerate(raw):
|
|
1080
|
+
if isinstance(entry, str) or not isinstance(entry, list) or not entry:
|
|
1081
|
+
raise ConfigurationError(
|
|
1082
|
+
f"{source}: {key}[{index}] must be a non-empty argv array such as "
|
|
1083
|
+
f'["pytest", "-q"] -- a shell command string is never accepted, got {entry!r}'
|
|
1084
|
+
)
|
|
1085
|
+
argv: list[str] = []
|
|
1086
|
+
for item in entry:
|
|
1087
|
+
if not isinstance(item, str) or not item.strip():
|
|
1088
|
+
raise ConfigurationError(
|
|
1089
|
+
f"{source}: {key}[{index}] must contain non-empty strings, got {item!r}"
|
|
1090
|
+
)
|
|
1091
|
+
# Stored verbatim, unlike `_as_str_list`: an argv element reaches
|
|
1092
|
+
# the process exactly as written, so surrounding whitespace is
|
|
1093
|
+
# part of the argument, not trimmed away. Only a blank element is
|
|
1094
|
+
# refused, since it can never be anything but a slip.
|
|
1095
|
+
argv.append(item)
|
|
1096
|
+
out.append(argv)
|
|
1097
|
+
return out
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _as_definitions(raw: object, source: str, key: str) -> list[list[str]]:
|
|
1101
|
+
"""A validation set: argv arrays within the bounds a record can hold (ADR 0006 D10.3).
|
|
1102
|
+
|
|
1103
|
+
``merge.verification_commands`` is copied into no record and is not
|
|
1104
|
+
bounded by this.
|
|
1105
|
+
"""
|
|
1106
|
+
commands = _as_argv_list(raw, source, key)
|
|
1107
|
+
try:
|
|
1108
|
+
check_definitions(commands)
|
|
1109
|
+
except ValidationDefinitionError as exc:
|
|
1110
|
+
raise ConfigurationError(f"{source}: {key}: {exc}") from None
|
|
1111
|
+
return commands
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _as_bool(raw: object, source: str, key: str) -> bool:
|
|
1115
|
+
"""Accept only a real boolean — never coerce strings like "false" to True."""
|
|
1116
|
+
if isinstance(raw, bool):
|
|
1117
|
+
return raw
|
|
1118
|
+
raise ConfigurationError(f"{source}: {key!r} must be a boolean (true/false), got {raw!r}")
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _as_int(raw: object, source: str, key: str) -> int:
|
|
1122
|
+
"""Accept only a real integer (bool excluded); raise ConfigurationError otherwise."""
|
|
1123
|
+
if isinstance(raw, int) and not isinstance(raw, bool):
|
|
1124
|
+
return raw
|
|
1125
|
+
raise ConfigurationError(f"{source}: {key!r} must be an integer, got {raw!r}")
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _optional_limit(raw: object, source: str, key: str) -> int | None:
|
|
1129
|
+
"""A limit in seconds, or ``None`` for ``null``."""
|
|
1130
|
+
if raw is None:
|
|
1131
|
+
return None
|
|
1132
|
+
value = _as_int(raw, source, key)
|
|
1133
|
+
_check_limit(value, source, key)
|
|
1134
|
+
return value
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _as_read_only_paths(raw: object, source: str) -> list[str]:
|
|
1138
|
+
"""``sandbox.read_only_paths``: absolute paths every sandboxed launch sees read-only (D15.4).
|
|
1139
|
+
|
|
1140
|
+
Each is checked with the rules that need no launch (C1, C3, C7, D4.3;
|
|
1141
|
+
:func:`autoforge.sandbox.check_declared_path`) against the controller's
|
|
1142
|
+
own home and environment, and no two may be equal or nested (C6). A
|
|
1143
|
+
refusal names the key, the entry and the rule, never a file's contents.
|
|
1144
|
+
"""
|
|
1145
|
+
key = "sandbox.read_only_paths"
|
|
1146
|
+
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
|
|
1147
|
+
raise ConfigurationError(
|
|
1148
|
+
f"{source}: '{key}' must be a list of absolute paths, e.g. ['/opt/toolchain']"
|
|
1149
|
+
)
|
|
1150
|
+
home = os.path.normpath(os.environ.get("HOME") or os.path.expanduser("~"))
|
|
1151
|
+
paths: list[str] = []
|
|
1152
|
+
for index, path in enumerate(raw):
|
|
1153
|
+
label = f"'{key}[{index}]'"
|
|
1154
|
+
try:
|
|
1155
|
+
check_declared_path(path, home, os.environ, label)
|
|
1156
|
+
except SandboxPolicyError as exc:
|
|
1157
|
+
raise ConfigurationError(f"{source}: {exc}") from None
|
|
1158
|
+
for other in paths:
|
|
1159
|
+
if path == other or path.startswith(other + "/") or other.startswith(path + "/"):
|
|
1160
|
+
raise ConfigurationError(
|
|
1161
|
+
f"{source}: {label} {path} overlaps {other}, another entry of '{key}' "
|
|
1162
|
+
"(C6 overlaps): declare the outer one alone"
|
|
1163
|
+
)
|
|
1164
|
+
paths.append(path)
|
|
1165
|
+
return paths
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _reject_unknown_keys(mapping: dict, known: tuple[str, ...], source: str, label: str) -> None:
|
|
1169
|
+
"""Fail on any key of ``mapping`` that the loader would not read.
|
|
1170
|
+
|
|
1171
|
+
Every known key is read with ``if key in section``, so a key that is not
|
|
1172
|
+
known is not a wrong value: it is a *silent no-op* that leaves the
|
|
1173
|
+
built-in default in force while ``doctor`` calls the file valid. For a
|
|
1174
|
+
loop bound or a merge setting that means a looser bound than the operator
|
|
1175
|
+
wrote, so the typo is an error, in the same shape for every section.
|
|
1176
|
+
"""
|
|
1177
|
+
unknown = sorted(str(k) for k in mapping if k not in known)
|
|
1178
|
+
if unknown:
|
|
1179
|
+
raise ConfigurationError(
|
|
1180
|
+
f"{source}: unknown key(s) under '{label}': {', '.join(unknown)} "
|
|
1181
|
+
f"(known: {', '.join(known)})"
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _section(
|
|
1186
|
+
data: dict,
|
|
1187
|
+
key: str,
|
|
1188
|
+
source: str,
|
|
1189
|
+
known: tuple[str, ...] | None,
|
|
1190
|
+
label: str | None = None,
|
|
1191
|
+
removed: dict[str, str] | None = None,
|
|
1192
|
+
) -> dict:
|
|
1193
|
+
"""A config section: absent or ``null`` is the built-in default, else a mapping.
|
|
1194
|
+
|
|
1195
|
+
``null`` is what the YAML reader produces for a section header with every
|
|
1196
|
+
child commented out (``safety:`` alone on its line), and an empty section
|
|
1197
|
+
yields the same fail-closed defaults as an omitted one. Any other
|
|
1198
|
+
non-mapping value (``[]``, ``false``, ``""``, ``0``, a string) is rejected
|
|
1199
|
+
rather than normalised into an omitted section: ``data.get(key, {}) or
|
|
1200
|
+
{}`` would turn ``safety: []`` into a silently closed gate and let ``doctor``
|
|
1201
|
+
call that configuration valid.
|
|
1202
|
+
|
|
1203
|
+
``known`` is the closed set of keys the section may contain; any other key
|
|
1204
|
+
is rejected (see ``_reject_unknown_keys``). ``None`` is for a section
|
|
1205
|
+
whose keys are *names* rather than a schema (``profiles``), where the
|
|
1206
|
+
check belongs on each named mapping instead. ``removed`` maps a key that
|
|
1207
|
+
used to be read to the message explaining where it went, so a config from
|
|
1208
|
+
an older controller gets that explanation rather than "unknown key".
|
|
1209
|
+
"""
|
|
1210
|
+
raw = data.get(key)
|
|
1211
|
+
if raw is None:
|
|
1212
|
+
return {}
|
|
1213
|
+
name = label or key
|
|
1214
|
+
if not isinstance(raw, dict):
|
|
1215
|
+
raise ConfigurationError(f"{source}: '{name}' must be a mapping, got {raw!r}")
|
|
1216
|
+
for old_key, message in (removed or {}).items():
|
|
1217
|
+
if old_key in raw:
|
|
1218
|
+
raise ConfigurationError(f"{source}: {message}")
|
|
1219
|
+
if known is not None:
|
|
1220
|
+
_reject_unknown_keys(raw, known, source, name)
|
|
1221
|
+
return raw
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
# The merge gate once had a second, deprecated location. It is not read any
|
|
1225
|
+
# more -- not even as `false` -- because a config that sets both can be
|
|
1226
|
+
# "disabled" in one place and still open in the other.
|
|
1227
|
+
REMOVED_EXECUTION_KEYS = {
|
|
1228
|
+
"allow_merge": (
|
|
1229
|
+
"'execution.allow_merge' is no longer supported; the merge gate is "
|
|
1230
|
+
"'safety.allow_merge' only. Remove the key from 'execution' (moving it to "
|
|
1231
|
+
"'safety' if you meant to open the gate)"
|
|
1232
|
+
),
|
|
1233
|
+
# One wall-clock value used to cover agents and controller-run commands
|
|
1234
|
+
# alike; agents now have an idle limit (#193) and commands keep theirs.
|
|
1235
|
+
"default_timeout_seconds": (
|
|
1236
|
+
"'execution.default_timeout_seconds' is no longer supported. Agents now run "
|
|
1237
|
+
"under 'execution.default_idle_timeout_seconds' (killed only after making no "
|
|
1238
|
+
"progress for that long; default 900) and an optional wall-clock ceiling "
|
|
1239
|
+
"'execution.default_max_runtime_seconds'; pre-merge verification and LOCAL "
|
|
1240
|
+
"validation commands run under 'execution.command_timeout_seconds' (default "
|
|
1241
|
+
"1800). Replace the key with the ones you need"
|
|
1242
|
+
),
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _merge_loop_detection(loop: LoopDetectionConfig, exe: dict, source: str) -> None:
|
|
1247
|
+
section = _section(
|
|
1248
|
+
exe, "loop_detection", source, LOOP_DETECTION_KEYS, label="execution.loop_detection"
|
|
1249
|
+
)
|
|
1250
|
+
if "mode" in section:
|
|
1251
|
+
mode = section["mode"]
|
|
1252
|
+
if not isinstance(mode, str) or mode not in LOOP_DETECTION_MODES:
|
|
1253
|
+
raise ConfigurationError(
|
|
1254
|
+
f"{source}: 'execution.loop_detection.mode' must be one of "
|
|
1255
|
+
f"{', '.join(LOOP_DETECTION_MODES)}, got {mode!r}"
|
|
1256
|
+
)
|
|
1257
|
+
loop.mode = mode
|
|
1258
|
+
# A repeat count of 1 is every action, and a period of 0 none: neither is
|
|
1259
|
+
# a loop bound, so both are refused rather than read as "off" (that is
|
|
1260
|
+
# what ``mode: off`` is for).
|
|
1261
|
+
for key, low, high in (
|
|
1262
|
+
("max_cycle_period", 1, MAX_LOOP_CYCLE_PERIOD),
|
|
1263
|
+
("max_cycle_repeats", 2, MAX_LOOP_REPEATS),
|
|
1264
|
+
("max_line_repeats", 2, MAX_LOOP_REPEATS),
|
|
1265
|
+
("novelty_window_seconds", 1, MAX_DEADLINE_SECONDS),
|
|
1266
|
+
):
|
|
1267
|
+
if key in section:
|
|
1268
|
+
value = _as_int(section[key], source, f"execution.loop_detection.{key}")
|
|
1269
|
+
if not low <= value <= high:
|
|
1270
|
+
raise ConfigurationError(
|
|
1271
|
+
f"{source}: 'execution.loop_detection.{key}' must be between "
|
|
1272
|
+
f"{low} and {high}, got {value}"
|
|
1273
|
+
)
|
|
1274
|
+
setattr(loop, key, value)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _merge_config(base: AutoForgeConfig, data: dict, source: str) -> AutoForgeConfig:
|
|
1278
|
+
_reject_unknown_keys(data, TOP_LEVEL_KEYS, source, "the top level")
|
|
1279
|
+
version = data.get("version", CONFIG_VERSION)
|
|
1280
|
+
if version != CONFIG_VERSION:
|
|
1281
|
+
raise ConfigurationError(
|
|
1282
|
+
f"{source}: unsupported config version {version!r} (expected {CONFIG_VERSION})"
|
|
1283
|
+
)
|
|
1284
|
+
if "state_dir" in data:
|
|
1285
|
+
base.state_dir = str(data["state_dir"])
|
|
1286
|
+
if "prompt_version" in data:
|
|
1287
|
+
base.prompt_version = str(data["prompt_version"])
|
|
1288
|
+
exe = _section(data, "execution", source, EXECUTION_KEYS, removed=REMOVED_EXECUTION_KEYS)
|
|
1289
|
+
# The executor treats a non-positive wall-clock timeout as "no timeout at
|
|
1290
|
+
# all", and an unbounded subprocess would hold the repository lock
|
|
1291
|
+
# forever, so every limit is > 0 (and within the executor's backstop).
|
|
1292
|
+
# Only the agents' ceiling may be absent (null).
|
|
1293
|
+
for key in ("default_idle_timeout_seconds", "command_timeout_seconds"):
|
|
1294
|
+
if key in exe:
|
|
1295
|
+
value = _as_int(exe[key], source, f"execution.{key}")
|
|
1296
|
+
_check_limit(value, source, f"execution.{key}")
|
|
1297
|
+
setattr(base.execution, key, value)
|
|
1298
|
+
if "default_max_runtime_seconds" in exe:
|
|
1299
|
+
base.execution.default_max_runtime_seconds = _optional_limit(
|
|
1300
|
+
exe["default_max_runtime_seconds"], source, "execution.default_max_runtime_seconds"
|
|
1301
|
+
)
|
|
1302
|
+
if "max_correction_attempts" in exe:
|
|
1303
|
+
base.execution.max_correction_attempts = _as_int(
|
|
1304
|
+
exe["max_correction_attempts"], source, "execution.max_correction_attempts"
|
|
1305
|
+
)
|
|
1306
|
+
for key in ("env_allowlist", "env_allowlist_extra"):
|
|
1307
|
+
if key in exe:
|
|
1308
|
+
names = _as_str_list(exe[key], source, f"execution.{key}")
|
|
1309
|
+
for name in names:
|
|
1310
|
+
if not is_env_pattern(name):
|
|
1311
|
+
raise ConfigurationError(
|
|
1312
|
+
f"{source}: 'execution.{key}' entry {name!r} is not an environment "
|
|
1313
|
+
"variable name or a name prefix followed by '*'"
|
|
1314
|
+
)
|
|
1315
|
+
refuse_forbidden_env(names, f"{source}: 'execution.{key}'")
|
|
1316
|
+
setattr(base.execution, key, names)
|
|
1317
|
+
if "env_allowlist" in exe and not base.execution.env_allowlist:
|
|
1318
|
+
# An empty environment cannot even find the agent CLI on PATH; the
|
|
1319
|
+
# operator who wants a narrower list writes the narrower list.
|
|
1320
|
+
raise ConfigurationError(
|
|
1321
|
+
f"{source}: 'execution.env_allowlist' must name at least one variable "
|
|
1322
|
+
"(remove the key to keep the default list, or use env_allowlist_extra to add)"
|
|
1323
|
+
)
|
|
1324
|
+
if "worktree_dir" in exe:
|
|
1325
|
+
raw_dir = exe["worktree_dir"]
|
|
1326
|
+
if raw_dir is None or raw_dir == "":
|
|
1327
|
+
base.execution.worktree_dir = ""
|
|
1328
|
+
elif not isinstance(raw_dir, str) or not raw_dir.strip():
|
|
1329
|
+
raise ConfigurationError(
|
|
1330
|
+
f"{source}: 'execution.worktree_dir' must be a path or null, got {raw_dir!r}"
|
|
1331
|
+
)
|
|
1332
|
+
else:
|
|
1333
|
+
base.execution.worktree_dir = raw_dir.strip()
|
|
1334
|
+
_merge_loop_detection(base.execution.loop_detection, exe, source)
|
|
1335
|
+
safety = _section(data, "safety", source, SAFETY_KEYS)
|
|
1336
|
+
if "allow_merge" in safety:
|
|
1337
|
+
base.safety.allow_merge = _as_bool(safety["allow_merge"], source, "safety.allow_merge")
|
|
1338
|
+
base.safety.allow_merge_source = f"{source}: safety.allow_merge"
|
|
1339
|
+
if "protected_merge_paths" in safety:
|
|
1340
|
+
base.safety.protected_merge_paths = _as_str_list(
|
|
1341
|
+
safety["protected_merge_paths"], source, "safety.protected_merge_paths"
|
|
1342
|
+
)
|
|
1343
|
+
if "required_checks" in safety:
|
|
1344
|
+
base.safety.required_checks = _as_str_list(
|
|
1345
|
+
safety["required_checks"], source, "safety.required_checks"
|
|
1346
|
+
)
|
|
1347
|
+
if "verify_check_definition" in safety:
|
|
1348
|
+
base.safety.verify_check_definition = _as_bool(
|
|
1349
|
+
safety["verify_check_definition"], source, "safety.verify_check_definition"
|
|
1350
|
+
)
|
|
1351
|
+
if "acceptance_actions" in safety:
|
|
1352
|
+
policy = safety["acceptance_actions"]
|
|
1353
|
+
# YAML 1.1 (and this parser, for parity) reads a bare `off` as false.
|
|
1354
|
+
# That is the one boolean the key accepts: `true` names no policy.
|
|
1355
|
+
if policy is False:
|
|
1356
|
+
policy = ACCEPTANCE_ACTIONS_OFF
|
|
1357
|
+
if not isinstance(policy, str) or policy not in ACCEPTANCE_ACTION_POLICIES:
|
|
1358
|
+
raise ConfigurationError(
|
|
1359
|
+
f"{source}: 'safety.acceptance_actions' must be one of "
|
|
1360
|
+
f"{ACCEPTANCE_ACTION_POLICIES}, got {policy!r}"
|
|
1361
|
+
)
|
|
1362
|
+
base.safety.acceptance_actions = policy
|
|
1363
|
+
gh = _section(data, "github", source, GITHUB_KEYS)
|
|
1364
|
+
if "command" in gh:
|
|
1365
|
+
base.github.command = str(gh["command"])
|
|
1366
|
+
if "timeout_seconds" in gh:
|
|
1367
|
+
base.github.timeout_seconds = _as_int(
|
|
1368
|
+
gh["timeout_seconds"], source, "github.timeout_seconds"
|
|
1369
|
+
)
|
|
1370
|
+
merge = _section(data, "merge", source, MERGE_KEYS)
|
|
1371
|
+
if "method" in merge:
|
|
1372
|
+
method = merge["method"]
|
|
1373
|
+
if not isinstance(method, str) or method not in MERGE_METHODS:
|
|
1374
|
+
raise ConfigurationError(
|
|
1375
|
+
f"{source}: 'merge.method' must be one of {MERGE_METHODS}, got {method!r}"
|
|
1376
|
+
)
|
|
1377
|
+
base.merge.method = method
|
|
1378
|
+
if "delete_branch" in merge:
|
|
1379
|
+
base.merge.delete_branch = _as_bool(merge["delete_branch"], source, "merge.delete_branch")
|
|
1380
|
+
if "max_verification_attempts" in merge:
|
|
1381
|
+
attempts = _as_int(
|
|
1382
|
+
merge["max_verification_attempts"], source, "merge.max_verification_attempts"
|
|
1383
|
+
)
|
|
1384
|
+
if attempts < 1:
|
|
1385
|
+
raise ConfigurationError(
|
|
1386
|
+
f"{source}: 'merge.max_verification_attempts' must be >= 1, got {attempts}"
|
|
1387
|
+
)
|
|
1388
|
+
base.merge.max_verification_attempts = attempts
|
|
1389
|
+
if "verification_commands" in merge:
|
|
1390
|
+
base.merge.verification_commands = _as_argv_list(
|
|
1391
|
+
merge["verification_commands"], source, "merge.verification_commands"
|
|
1392
|
+
)
|
|
1393
|
+
scheduler = _section(data, "scheduler", source, SCHEDULER_KEYS)
|
|
1394
|
+
for key, (low, high) in (
|
|
1395
|
+
("max_parallel", MAX_PARALLEL_BOUNDS),
|
|
1396
|
+
("poll_interval_seconds", POLL_INTERVAL_BOUNDS),
|
|
1397
|
+
("stop_grace_seconds", STOP_GRACE_BOUNDS),
|
|
1398
|
+
):
|
|
1399
|
+
if key in scheduler:
|
|
1400
|
+
value = _as_int(scheduler[key], source, f"scheduler.{key}")
|
|
1401
|
+
if not low <= value <= high:
|
|
1402
|
+
raise ConfigurationError(
|
|
1403
|
+
f"{source}: 'scheduler.{key}' must be between {low} and {high}, got "
|
|
1404
|
+
f"{value} (there is no unbounded mode)"
|
|
1405
|
+
)
|
|
1406
|
+
setattr(base.scheduler, key, value)
|
|
1407
|
+
review = _section(data, "review", source, REVIEW_KEYS)
|
|
1408
|
+
replan = _section(review, "replan", source, REPLAN_KEYS, label="review.replan")
|
|
1409
|
+
rp = base.review.replan
|
|
1410
|
+
if "enabled" in replan:
|
|
1411
|
+
rp.enabled = _as_bool(replan["enabled"], source, "review.replan.enabled")
|
|
1412
|
+
for key, minimum in (
|
|
1413
|
+
("soft_threshold", 1),
|
|
1414
|
+
("hard_threshold", 1),
|
|
1415
|
+
("stagnation_window", 1),
|
|
1416
|
+
("max_findings_per_round", 0),
|
|
1417
|
+
("max_replans_per_issue", 0),
|
|
1418
|
+
):
|
|
1419
|
+
if key in replan:
|
|
1420
|
+
value = _as_int(replan[key], source, f"review.replan.{key}")
|
|
1421
|
+
if value < minimum:
|
|
1422
|
+
raise ConfigurationError(
|
|
1423
|
+
f"{source}: 'review.replan.{key}' must be >= {minimum}, got {value}"
|
|
1424
|
+
)
|
|
1425
|
+
setattr(rp, key, value)
|
|
1426
|
+
if rp.hard_threshold < rp.soft_threshold:
|
|
1427
|
+
raise ConfigurationError(
|
|
1428
|
+
f"{source}: 'review.replan.hard_threshold' must be >= 'review.replan.soft_threshold'"
|
|
1429
|
+
)
|
|
1430
|
+
workflow = _section(data, "workflow", source, WORKFLOW_KEYS)
|
|
1431
|
+
for key, minimum in (
|
|
1432
|
+
("max_review_rounds", 1),
|
|
1433
|
+
("max_total_steps", 1),
|
|
1434
|
+
("epic_update_every", 1),
|
|
1435
|
+
):
|
|
1436
|
+
if key in workflow:
|
|
1437
|
+
value = _as_int(workflow[key], source, f"workflow.{key}")
|
|
1438
|
+
if value < minimum:
|
|
1439
|
+
raise ConfigurationError(
|
|
1440
|
+
f"{source}: 'workflow.{key}' must be >= {minimum}, got {value}"
|
|
1441
|
+
)
|
|
1442
|
+
setattr(base.workflow, key, value)
|
|
1443
|
+
# Both stagnation rules compare consecutive rounds *against each other*, so
|
|
1444
|
+
# a window of 1 has no meaning: it would compare a round with nothing and
|
|
1445
|
+
# silently disable the unchanged-count rule (which needs a recurrence
|
|
1446
|
+
# inside the window) while making the identical-resolutions rule fire on
|
|
1447
|
+
# the first round that has findings at all. Only 0 disables a rule.
|
|
1448
|
+
for key in ("stagnation_identical_rounds", "stagnation_unchanged_count_rounds"):
|
|
1449
|
+
if key in workflow:
|
|
1450
|
+
value = _as_int(workflow[key], source, f"workflow.{key}")
|
|
1451
|
+
if value < 0 or value == 1:
|
|
1452
|
+
raise ConfigurationError(
|
|
1453
|
+
f"{source}: 'workflow.{key}' must be 0 (rule disabled) or >= 2 "
|
|
1454
|
+
f"(the rule compares consecutive review rounds), got {value}"
|
|
1455
|
+
)
|
|
1456
|
+
setattr(base.workflow, key, value)
|
|
1457
|
+
if base.review.replan.hard_threshold > base.workflow.max_review_rounds:
|
|
1458
|
+
raise ConfigurationError(
|
|
1459
|
+
f"{source}: 'review.replan.hard_threshold' must be <= 'workflow.max_review_rounds'"
|
|
1460
|
+
)
|
|
1461
|
+
local = _section(data, "local", source, LOCAL_KEYS)
|
|
1462
|
+
if "feature_dir" in local:
|
|
1463
|
+
feature_dir = str(local["feature_dir"]).strip()
|
|
1464
|
+
if not feature_dir:
|
|
1465
|
+
raise ConfigurationError(f"{source}: 'local.feature_dir' must not be empty")
|
|
1466
|
+
base.local.feature_dir = feature_dir
|
|
1467
|
+
if "validation_commands" in local:
|
|
1468
|
+
base.local.validation_commands = _as_definitions(
|
|
1469
|
+
local["validation_commands"], source, "local.validation_commands"
|
|
1470
|
+
)
|
|
1471
|
+
if "max_fix_rounds" in local:
|
|
1472
|
+
value = _as_int(local["max_fix_rounds"], source, "local.max_fix_rounds")
|
|
1473
|
+
if value < 0:
|
|
1474
|
+
raise ConfigurationError(f"{source}: 'local.max_fix_rounds' must be >= 0, got {value}")
|
|
1475
|
+
base.local.max_fix_rounds = value
|
|
1476
|
+
if "exclude" in local:
|
|
1477
|
+
raw = local["exclude"]
|
|
1478
|
+
if isinstance(raw, str) or not isinstance(raw, list):
|
|
1479
|
+
raise ConfigurationError(
|
|
1480
|
+
f"{source}: 'local.exclude' must be a list of path patterns, e.g. "
|
|
1481
|
+
"['.venv', '**/__pycache__']"
|
|
1482
|
+
)
|
|
1483
|
+
patterns: list[str] = []
|
|
1484
|
+
for item in raw:
|
|
1485
|
+
try:
|
|
1486
|
+
patterns.append(normalize_exclude_pattern(item))
|
|
1487
|
+
except ConfigurationError as exc:
|
|
1488
|
+
raise ConfigurationError(f"{source}: {exc}") from None
|
|
1489
|
+
base.local.exclude = sorted(dict.fromkeys(patterns))
|
|
1490
|
+
for name, attr, minimum in (
|
|
1491
|
+
("max_workspace_entries", "max_workspace_entries", 1),
|
|
1492
|
+
("max_workspace_bytes", "max_workspace_bytes", 1),
|
|
1493
|
+
):
|
|
1494
|
+
if name in local:
|
|
1495
|
+
value = _as_int(local[name], source, f"local.{name}")
|
|
1496
|
+
if value < minimum:
|
|
1497
|
+
raise ConfigurationError(
|
|
1498
|
+
f"{source}: 'local.{name}' must be >= {minimum}, got {value}"
|
|
1499
|
+
)
|
|
1500
|
+
setattr(base.local, attr, value)
|
|
1501
|
+
sandbox = _section(data, "sandbox", source, SANDBOX_KEYS)
|
|
1502
|
+
for key, allowed in (
|
|
1503
|
+
("mode", SANDBOX_MODES),
|
|
1504
|
+
("validation_network", SANDBOX_NETWORKS),
|
|
1505
|
+
("premerge_network", SANDBOX_NETWORKS),
|
|
1506
|
+
):
|
|
1507
|
+
if key in sandbox:
|
|
1508
|
+
value = sandbox[key]
|
|
1509
|
+
if not isinstance(value, str) or value not in allowed:
|
|
1510
|
+
raise ConfigurationError(
|
|
1511
|
+
f"{source}: 'sandbox.{key}' must be one of {', '.join(allowed)}, got {value!r}"
|
|
1512
|
+
)
|
|
1513
|
+
setattr(base.sandbox, key, value)
|
|
1514
|
+
if "read_only_paths" in sandbox:
|
|
1515
|
+
base.sandbox.read_only_paths = _as_read_only_paths(sandbox["read_only_paths"], source)
|
|
1516
|
+
validation = _section(data, "validation", source, VALIDATION_KEYS)
|
|
1517
|
+
if "commands" in validation:
|
|
1518
|
+
base.validation.commands = _as_definitions(
|
|
1519
|
+
validation["commands"], source, "validation.commands"
|
|
1520
|
+
)
|
|
1521
|
+
if "scope" in validation:
|
|
1522
|
+
try:
|
|
1523
|
+
check_scope(validation["scope"])
|
|
1524
|
+
except ConfigurationError as exc:
|
|
1525
|
+
raise ConfigurationError(f"{source}: {exc}") from None
|
|
1526
|
+
base.validation.scope = SCOPE
|
|
1527
|
+
# Keyed by profile *name*, so the closed key set applies to each profile
|
|
1528
|
+
# mapping rather than to the section.
|
|
1529
|
+
profiles = _section(data, "profiles", source, known=None)
|
|
1530
|
+
for name, p in profiles.items():
|
|
1531
|
+
# YAML keys are not necessarily strings (`1:`, `true:`, `null:`), and
|
|
1532
|
+
# a name is looked up, sorted and printed as one: a non-string would
|
|
1533
|
+
# load here and fail as a `TypeError` in `doctor` instead.
|
|
1534
|
+
if not isinstance(name, str) or not name.strip():
|
|
1535
|
+
raise ConfigurationError(
|
|
1536
|
+
f"{source}: profile names must be non-empty strings, got {name!r}"
|
|
1537
|
+
)
|
|
1538
|
+
if not isinstance(p, dict):
|
|
1539
|
+
raise ConfigurationError(f"{source}: profile {name!r} must be a mapping")
|
|
1540
|
+
for old_key, message in REMOVED_PROFILE_KEYS.items():
|
|
1541
|
+
if old_key in p:
|
|
1542
|
+
raise ConfigurationError(
|
|
1543
|
+
f"{source}: profiles.{name}: "
|
|
1544
|
+
+ message.format(idle=base.execution.default_idle_timeout_seconds)
|
|
1545
|
+
)
|
|
1546
|
+
_reject_unknown_keys(p, PROFILE_KEYS, source, f"profiles.{name}")
|
|
1547
|
+
# Typed here, range-checked when the profile is validated: a config
|
|
1548
|
+
# may carry profiles a given run never reaches (``validate_profile``).
|
|
1549
|
+
limits = {
|
|
1550
|
+
key: None if p[key] is None else _as_int(p[key], source, f"profiles.{name}.{key}")
|
|
1551
|
+
for key in ("idle_timeout_seconds", "max_runtime_seconds")
|
|
1552
|
+
if key in p
|
|
1553
|
+
}
|
|
1554
|
+
cur = base.profiles.get(name)
|
|
1555
|
+
if cur is not None and "provider" in p and str(p["provider"]) != cur.provider:
|
|
1556
|
+
# A different provider is a different CLI: nothing the default
|
|
1557
|
+
# profile carried for the old one (its `command`, its options,
|
|
1558
|
+
# its extra arguments) may survive into the new one, or the
|
|
1559
|
+
# override would launch the old binary with the new model.
|
|
1560
|
+
cur = None
|
|
1561
|
+
if cur is not None:
|
|
1562
|
+
if "model" in p:
|
|
1563
|
+
cur.model = str(p["model"])
|
|
1564
|
+
if "effort" in p:
|
|
1565
|
+
cur.effort = str(p["effort"])
|
|
1566
|
+
if "command" in p:
|
|
1567
|
+
cur.command = str(p["command"])
|
|
1568
|
+
if "extra_args" in p:
|
|
1569
|
+
cur.extra_args = [str(a) for a in (p["extra_args"] or [])]
|
|
1570
|
+
for key, limit in limits.items():
|
|
1571
|
+
setattr(cur, key, limit)
|
|
1572
|
+
if "options" in p:
|
|
1573
|
+
cur.options.update(_as_options(p["options"], source, name))
|
|
1574
|
+
else:
|
|
1575
|
+
# A profile the defaults do not have, or one whose provider the
|
|
1576
|
+
# override changes: built from the override alone.
|
|
1577
|
+
base.profiles[name] = ProfileConfig(
|
|
1578
|
+
name=name,
|
|
1579
|
+
provider=str(p.get("provider", "opencode")),
|
|
1580
|
+
model=str(p.get("model", "")),
|
|
1581
|
+
effort=str(p.get("effort", "high")),
|
|
1582
|
+
command=str(p.get("command", "")),
|
|
1583
|
+
extra_args=[str(a) for a in (p.get("extra_args") or [])],
|
|
1584
|
+
idle_timeout_seconds=limits.get("idle_timeout_seconds"),
|
|
1585
|
+
max_runtime_seconds=limits.get("max_runtime_seconds"),
|
|
1586
|
+
options=_as_options(p.get("options"), source, name),
|
|
1587
|
+
)
|
|
1588
|
+
return base
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def _load_yaml(path: Path) -> object:
|
|
1592
|
+
"""The YAML document as parsed; ``load_config_file`` checks the root type.
|
|
1593
|
+
|
|
1594
|
+
Only an *empty* document (no content, or comments only) reads as ``{}``:
|
|
1595
|
+
that is what the subset parser produces for it, and it means "all
|
|
1596
|
+
defaults" the same way an omitted file does. Any other non-mapping root
|
|
1597
|
+
(``false``, a list, a bare string) is returned as is so the root check
|
|
1598
|
+
rejects it on both backends alike, rather than PyYAML alone reading it as
|
|
1599
|
+
the built-in configuration.
|
|
1600
|
+
"""
|
|
1601
|
+
text = path.read_text(encoding="utf-8")
|
|
1602
|
+
try:
|
|
1603
|
+
import yaml # type: ignore
|
|
1604
|
+
except ImportError:
|
|
1605
|
+
return _minimal_yaml_parse(text)
|
|
1606
|
+
|
|
1607
|
+
class UniqueKeySafeLoader(yaml.SafeLoader):
|
|
1608
|
+
"""``SafeLoader`` that refuses a key repeated within one mapping.
|
|
1609
|
+
|
|
1610
|
+
PyYAML keeps the last value of a repeated key, so the earlier one --
|
|
1611
|
+
and any typo in it -- would vanish before ``_reject_unknown_keys``
|
|
1612
|
+
runs. Merge keys (``<<``) are left to PyYAML: overriding a merged
|
|
1613
|
+
key is what they are for, not a duplicate.
|
|
1614
|
+
"""
|
|
1615
|
+
|
|
1616
|
+
def construct_mapping(self, node: object, deep: bool = False) -> dict:
|
|
1617
|
+
seen: set[object] = set()
|
|
1618
|
+
for key_node, _value_node in node.value: # type: ignore[attr-defined]
|
|
1619
|
+
if key_node.tag == "tag:yaml.org,2002:merge":
|
|
1620
|
+
continue
|
|
1621
|
+
key = self.construct_object(key_node, deep=deep)
|
|
1622
|
+
try:
|
|
1623
|
+
repeated = key in seen
|
|
1624
|
+
except TypeError: # unhashable key: PyYAML reports it below
|
|
1625
|
+
continue
|
|
1626
|
+
if repeated:
|
|
1627
|
+
raise ValueError(f"duplicate key {key!r} (line {key_node.start_mark.line + 1})")
|
|
1628
|
+
seen.add(key)
|
|
1629
|
+
return super().construct_mapping(node, deep=deep)
|
|
1630
|
+
|
|
1631
|
+
data = yaml.load(text, Loader=UniqueKeySafeLoader) # a SafeLoader subclass
|
|
1632
|
+
return {} if data is None else data
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _minimal_yaml_parse(text: str) -> object:
|
|
1636
|
+
"""Minimal YAML-subset parser for our config shape.
|
|
1637
|
+
|
|
1638
|
+
Supports: nested maps via 2-space indentation, lists via "- " items,
|
|
1639
|
+
inline scalars (int/float/bool/null/quoted strings), each resolved
|
|
1640
|
+
exactly as PyYAML resolves it. Anything fancier raises ValueError telling
|
|
1641
|
+
the user to install PyYAML rather than reading it as the string it looks
|
|
1642
|
+
like (see the note below); like PyYAML's own errors it reaches the
|
|
1643
|
+
operator as ``cannot parse config <path>: ...``, and like PyYAML the root
|
|
1644
|
+
is returned as parsed for ``load_config_file`` to check.
|
|
1645
|
+
"""
|
|
1646
|
+
return _parse_yaml_subset(text)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
# -- the YAML subset parser ------------------------------------------------
|
|
1650
|
+
#
|
|
1651
|
+
# Two backends read the same `.yaml` file: PyYAML for an operator who
|
|
1652
|
+
# installed `autoforge-cli[yaml]`, this parser for one who did not. A scalar the
|
|
1653
|
+
# two resolve *differently* is the failure mode the parser below is built
|
|
1654
|
+
# against (#40). The file it feeds decides routing, loop bounds, timeouts and
|
|
1655
|
+
# `safety.allow_merge`, and nothing downstream can tell that `on` was read as
|
|
1656
|
+
# a string here and as `True` there, or `0600` as 600 here and 384 there.
|
|
1657
|
+
#
|
|
1658
|
+
# The rule is therefore: resolve exactly what PyYAML's YAML 1.1 implicit
|
|
1659
|
+
# resolvers resolve, to exactly the same value, and refuse everything else
|
|
1660
|
+
# outright. A refusal reaches the operator as `cannot parse config <path>`;
|
|
1661
|
+
# a quietly different value reaches them as a different controller. Whatever
|
|
1662
|
+
# this parser accepts is proven equal to PyYAML's reading of it, scalar by
|
|
1663
|
+
# scalar and document by document, in `tests/test_config.py`.
|
|
1664
|
+
|
|
1665
|
+
# PyYAML's implicit resolvers (`yaml/resolver.py`), reproduced. Anything a
|
|
1666
|
+
# resolver claims that this parser does not implement -- a timestamp -- is
|
|
1667
|
+
# refused rather than kept as the string it looks like.
|
|
1668
|
+
_YAML_NULL = frozenset({"", "~", "null", "Null", "NULL"})
|
|
1669
|
+
_YAML_TRUE = frozenset({"yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"})
|
|
1670
|
+
_YAML_FALSE = frozenset({"no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"})
|
|
1671
|
+
|
|
1672
|
+
_YAML_INT_RE = re.compile(
|
|
1673
|
+
r"""^(?:[-+]?0b[0-1_]+
|
|
1674
|
+
|[-+]?0[0-7_]+
|
|
1675
|
+
|[-+]?(?:0|[1-9][0-9_]*)
|
|
1676
|
+
|[-+]?0x[0-9a-fA-F_]+
|
|
1677
|
+
|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$""",
|
|
1678
|
+
re.X,
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
_YAML_FLOAT_RE = re.compile(
|
|
1682
|
+
r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?
|
|
1683
|
+
|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?
|
|
1684
|
+
|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*
|
|
1685
|
+
|[-+]?\.(?:inf|Inf|INF)
|
|
1686
|
+
|\.(?:nan|NaN|NAN))$""",
|
|
1687
|
+
re.X,
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
_YAML_TIMESTAMP_RE = re.compile(
|
|
1691
|
+
r"""^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]
|
|
1692
|
+
|[0-9][0-9][0-9][0-9] -[0-9][0-9]? -[0-9][0-9]?
|
|
1693
|
+
(?:[Tt]|[ \t]+)[0-9][0-9]?
|
|
1694
|
+
:[0-9][0-9] :[0-9][0-9] (?:\.[0-9]*)?
|
|
1695
|
+
(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$""",
|
|
1696
|
+
re.X,
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
# A plain scalar never starts with one of these: each introduces YAML this
|
|
1700
|
+
# parser does not implement (anchor, alias, tag, block scalar, flow mapping,
|
|
1701
|
+
# directive, reserved indicator) or is a syntax error under PyYAML. Reading
|
|
1702
|
+
# any of them as the string it looks like is exactly the silent disagreement
|
|
1703
|
+
# to avoid, so they are refused.
|
|
1704
|
+
_UNSUPPORTED_FIRST_CHARS = "&*!|>{}],%@`"
|
|
1705
|
+
|
|
1706
|
+
# YAML's whitespace, line breaks and printable set -- none of which is
|
|
1707
|
+
# Python's. Between tokens PyYAML's scanner skips a space or a tab and nothing
|
|
1708
|
+
# else, so every *other* character `str.strip()` treats as whitespace (U+00A0,
|
|
1709
|
+
# U+1680, U+2000-U+200A, U+202F, U+205F, U+3000) is an ordinary character of
|
|
1710
|
+
# the plain scalar it sits in. Stripping lines, keys, values and items with
|
|
1711
|
+
# `str.strip()` dropped them silently: `safety.allow_merge: <NBSP>true`
|
|
1712
|
+
# resolved to True and opened the merge gate here, while PyYAML read the
|
|
1713
|
+
# string '\xa0true' and the loader refused the file. `str.splitlines()` is the
|
|
1714
|
+
# same mistake one layer down -- it breaks on U+000B, U+000C, U+001C, U+001D
|
|
1715
|
+
# and U+001E, which are not line breaks in YAML but characters PyYAML's
|
|
1716
|
+
# *reader* refuses outright, so `version: 1<U+000C>safety:` was read here as
|
|
1717
|
+
# two lines and opened the gate against a file the other backend cannot load.
|
|
1718
|
+
_YAML_WHITESPACE = " \t"
|
|
1719
|
+
|
|
1720
|
+
# `\n`, `\r\n`, `\r`, U+0085, U+2028 and U+2029 -- `yaml.scanner.scan_line_break`.
|
|
1721
|
+
_YAML_LINE_BREAK_RE = re.compile("\r\n|[\n\r\x85\u2028\u2029]")
|
|
1722
|
+
|
|
1723
|
+
# `yaml.reader.Reader.NON_PRINTABLE`, reproduced.
|
|
1724
|
+
_YAML_NON_PRINTABLE_RE = re.compile(
|
|
1725
|
+
"[^\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]"
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _yaml_strip(text: str) -> str:
|
|
1730
|
+
"""Strip YAML's whitespace -- a space or a tab -- and nothing else.
|
|
1731
|
+
|
|
1732
|
+
A Unicode space is kept because PyYAML keeps it: it is a character of the
|
|
1733
|
+
scalar, and dropping it made the two backends read the same file
|
|
1734
|
+
differently (see the note above).
|
|
1735
|
+
"""
|
|
1736
|
+
return text.strip(_YAML_WHITESPACE)
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def _yaml_lines(text: str) -> list[str]:
|
|
1740
|
+
"""Split a document on YAML's line breaks, not on Python's wider set."""
|
|
1741
|
+
return _YAML_LINE_BREAK_RE.split(text)
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _unsupported(what: str, text: str) -> ValueError:
|
|
1745
|
+
"""The parser's one error shape: what it could not read, and the way out."""
|
|
1746
|
+
return ValueError(
|
|
1747
|
+
f"YAML subset parser: {what}: {text!r} — install PyYAML for full YAML support"
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _parse_yaml_subset(text: str) -> object:
|
|
1752
|
+
"""Small recursive indentation-based parser for the documented subset.
|
|
1753
|
+
|
|
1754
|
+
Raises only ``ValueError``: this parser is one backend among four and
|
|
1755
|
+
knows nothing about the file it is reading, so the path that makes the
|
|
1756
|
+
message actionable is added by ``load_config_file`` for all of them.
|
|
1757
|
+
"""
|
|
1758
|
+
lines = _yaml_lines(text)
|
|
1759
|
+
for line in lines:
|
|
1760
|
+
_reject_non_printable(line)
|
|
1761
|
+
if "\t" in line:
|
|
1762
|
+
_reject_tab(line)
|
|
1763
|
+
cleaned: list[tuple[int, str]] = []
|
|
1764
|
+
for line in lines:
|
|
1765
|
+
# Indentation is spaces alone: a tab cannot indent under PyYAML and
|
|
1766
|
+
# the pass above has already refused every bare one, and a Unicode
|
|
1767
|
+
# space is a character of the scalar rather than indentation.
|
|
1768
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
1769
|
+
stripped = _yaml_strip(line)
|
|
1770
|
+
if not stripped or stripped.startswith("#"):
|
|
1771
|
+
continue
|
|
1772
|
+
content = _strip_inline_comment(stripped)
|
|
1773
|
+
if content:
|
|
1774
|
+
cleaned.append((indent, content))
|
|
1775
|
+
for _, content in cleaned:
|
|
1776
|
+
# `---` and `...` open and close a *document*; a file with more than
|
|
1777
|
+
# one is a PyYAML error, and this parser reads a single document, so
|
|
1778
|
+
# it never treats either marker as the content it looks like.
|
|
1779
|
+
if content in ("---", "...") or content[:4] in ("--- ", "... "):
|
|
1780
|
+
raise _unsupported("YAML document markers are not supported at", content)
|
|
1781
|
+
pos = 0
|
|
1782
|
+
|
|
1783
|
+
def is_list_item(content: str) -> bool:
|
|
1784
|
+
return content == "-" or content.startswith("- ")
|
|
1785
|
+
|
|
1786
|
+
def parse_block(min_indent: int) -> object:
|
|
1787
|
+
nonlocal pos
|
|
1788
|
+
# decide dict vs list by first line
|
|
1789
|
+
if pos >= len(cleaned) or cleaned[pos][0] < min_indent:
|
|
1790
|
+
return {}
|
|
1791
|
+
if is_list_item(cleaned[pos][1]):
|
|
1792
|
+
items: list[object] = []
|
|
1793
|
+
# Items of one sequence align exactly. A *deeper* `- x` line is
|
|
1794
|
+
# not a sibling to PyYAML but the continuation of the multi-line
|
|
1795
|
+
# plain scalar above it (`- a` then ` - b` is the one item
|
|
1796
|
+
# "a - b" there), so leave it to the caller, which refuses it.
|
|
1797
|
+
item_indent = cleaned[pos][0]
|
|
1798
|
+
while (
|
|
1799
|
+
pos < len(cleaned)
|
|
1800
|
+
and cleaned[pos][0] == item_indent
|
|
1801
|
+
and is_list_item(cleaned[pos][1])
|
|
1802
|
+
):
|
|
1803
|
+
ind, content = cleaned[pos]
|
|
1804
|
+
item_text = _yaml_strip(content[1:])
|
|
1805
|
+
pos += 1
|
|
1806
|
+
if item_text == "":
|
|
1807
|
+
# An item with nothing after the dash is a nested block,
|
|
1808
|
+
# or -- with nothing indented under it -- null, which is
|
|
1809
|
+
# what PyYAML builds for it; an empty mapping is not.
|
|
1810
|
+
if pos < len(cleaned) and cleaned[pos][0] > ind:
|
|
1811
|
+
items.append(parse_block(ind + 1))
|
|
1812
|
+
else:
|
|
1813
|
+
items.append(None)
|
|
1814
|
+
elif _split_key(item_text) is not None:
|
|
1815
|
+
# `- key: value` is a one-key *mapping* to PyYAML, and
|
|
1816
|
+
# `- key: value` + an aligned second key is a two-key one.
|
|
1817
|
+
# Resolving the item as a scalar kept it as the string it
|
|
1818
|
+
# looks like, so `required_checks:\n - x: y` installed the
|
|
1819
|
+
# check "x: y" here while PyYAML built {"x": "y"} and the
|
|
1820
|
+
# loader refused the file. This parser implements no
|
|
1821
|
+
# mapping inside a sequence, so it refuses the item.
|
|
1822
|
+
raise _unsupported("a mapping inside a sequence item at", content)
|
|
1823
|
+
else:
|
|
1824
|
+
items.append(_scalar(item_text))
|
|
1825
|
+
return items
|
|
1826
|
+
# Keys are resolved like any other scalar, because PyYAML resolves
|
|
1827
|
+
# them too: `1:` is the integer 1 and `~:` is None there, and the
|
|
1828
|
+
# loader's "profile names must be non-empty strings" check is written
|
|
1829
|
+
# for exactly that. A key kept as the string it looks like would pass
|
|
1830
|
+
# the check on this backend and fail it on the other.
|
|
1831
|
+
mapping: dict[object, object] = {}
|
|
1832
|
+
while pos < len(cleaned) and cleaned[pos][0] >= min_indent:
|
|
1833
|
+
ind, content = cleaned[pos]
|
|
1834
|
+
if ind != min_indent:
|
|
1835
|
+
# Over-indented stray line (mapping keys must align).
|
|
1836
|
+
raise _unsupported("bad indentation at", content)
|
|
1837
|
+
if is_list_item(content):
|
|
1838
|
+
break
|
|
1839
|
+
split = _split_key(content)
|
|
1840
|
+
if split is None:
|
|
1841
|
+
raise _unsupported("cannot parse line", content)
|
|
1842
|
+
raw_key, rest = split
|
|
1843
|
+
if raw_key == "":
|
|
1844
|
+
# `: 1` is an explicit-key mapping to PyYAML, which refuses
|
|
1845
|
+
# it in block context; only a *quoted* empty key is one.
|
|
1846
|
+
raise _unsupported("a mapping key is missing at", content)
|
|
1847
|
+
key = _scalar(raw_key)
|
|
1848
|
+
try:
|
|
1849
|
+
repeated = key in mapping
|
|
1850
|
+
except TypeError:
|
|
1851
|
+
raise _unsupported("unhashable key at", content) from None
|
|
1852
|
+
if repeated:
|
|
1853
|
+
raise ValueError(f"duplicate key {key!r} at: {content!r}")
|
|
1854
|
+
pos += 1
|
|
1855
|
+
if rest == "":
|
|
1856
|
+
# look ahead: deeper indent -> nested block, else None
|
|
1857
|
+
if pos < len(cleaned) and cleaned[pos][0] > ind:
|
|
1858
|
+
mapping[key] = parse_block(cleaned[pos][0])
|
|
1859
|
+
else:
|
|
1860
|
+
mapping[key] = None
|
|
1861
|
+
else:
|
|
1862
|
+
mapping[key] = _scalar(rest)
|
|
1863
|
+
return mapping
|
|
1864
|
+
|
|
1865
|
+
root = parse_block(0)
|
|
1866
|
+
# `parse_block` returns at the first line that does not belong to the
|
|
1867
|
+
# block it is reading: a nested one leaves that line to its caller, which
|
|
1868
|
+
# refuses it, but the top-level call has no caller. So a document that
|
|
1869
|
+
# mixes a mapping and a sequence at the root -- `safety:\n allow_merge:
|
|
1870
|
+
# true\n- x\n` -- used to be returned as the part read before the stray
|
|
1871
|
+
# line, silently dropping content PyYAML rejects outright. Reading *part*
|
|
1872
|
+
# of a malformed file is the one outcome this parser must never produce:
|
|
1873
|
+
# the dropped line could be the one that closes the merge gate.
|
|
1874
|
+
if pos != len(cleaned):
|
|
1875
|
+
raise _unsupported("unexpected content after the document at", cleaned[pos][1])
|
|
1876
|
+
return root
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def _scan_line(content: str) -> Iterator[tuple[str, bool]]:
|
|
1880
|
+
r"""Yield ``(char, quoted)`` for the part of a line that is not a comment.
|
|
1881
|
+
|
|
1882
|
+
One scan serves both callers below, because both need PyYAML's two
|
|
1883
|
+
boundary rules and must not disagree about them:
|
|
1884
|
+
|
|
1885
|
+
* A quote *opens* a quoted scalar only where a scalar may **begin** -- at
|
|
1886
|
+
the start of the line, after the ``: `` or ``- `` that introduces a
|
|
1887
|
+
value or a block sequence item, or after a ``,``, ``[`` or ``{`` inside
|
|
1888
|
+
a flow collection. A quote anywhere else is an ordinary character of
|
|
1889
|
+
the plain scalar already being read, which is why ``model: don't # why``
|
|
1890
|
+
keeps its comment and ``model: a 'b # c'`` is the plain scalar ``a 'b``
|
|
1891
|
+
rather than a quoted scalar swallowing the ``#``. Treating *any* space
|
|
1892
|
+
as a scalar start read the first as PyYAML does and the second as a
|
|
1893
|
+
quoted scalar, and let ``model: a 'b\tc'`` past the tab rule below.
|
|
1894
|
+
* A ``#`` starts a comment only after whitespace (or at the start of the
|
|
1895
|
+
line), and everything from there on is comment, so the scan stops.
|
|
1896
|
+
"""
|
|
1897
|
+
quote = ""
|
|
1898
|
+
at_start = True # a scalar may begin at the start of a line
|
|
1899
|
+
depth = 0 # open flow collections: `,` separates items only inside one
|
|
1900
|
+
prev = " "
|
|
1901
|
+
for i, ch in enumerate(content):
|
|
1902
|
+
if quote:
|
|
1903
|
+
yield ch, True
|
|
1904
|
+
if ch == quote:
|
|
1905
|
+
quote = ""
|
|
1906
|
+
at_start = False
|
|
1907
|
+
elif ch in "'\"" and at_start:
|
|
1908
|
+
quote = ch
|
|
1909
|
+
yield ch, True
|
|
1910
|
+
elif ch == "#" and (at_start or prev in " \t"):
|
|
1911
|
+
return
|
|
1912
|
+
else:
|
|
1913
|
+
yield ch, False
|
|
1914
|
+
if ch in " \t":
|
|
1915
|
+
pass # whitespace separates tokens; it does not end a scalar
|
|
1916
|
+
elif ch in "[{" and at_start:
|
|
1917
|
+
depth += 1 # a flow collection, whose first item begins next
|
|
1918
|
+
elif ch in "]}" and depth:
|
|
1919
|
+
depth -= 1
|
|
1920
|
+
at_start = False
|
|
1921
|
+
elif ch == "," and depth:
|
|
1922
|
+
at_start = True # the next item of that flow collection
|
|
1923
|
+
elif ch == ":" and content[i + 1 : i + 2] in ("", " "):
|
|
1924
|
+
at_start = True # a mapping value begins after the colon
|
|
1925
|
+
elif ch == "-" and at_start and content[i + 1 : i + 2] == " ":
|
|
1926
|
+
pass # a block sequence indicator: the item begins after it
|
|
1927
|
+
else:
|
|
1928
|
+
at_start = False
|
|
1929
|
+
prev = ch
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def _strip_inline_comment(content: str) -> str:
|
|
1933
|
+
"""Drop a trailing ``#`` comment, ignoring one inside a quoted scalar."""
|
|
1934
|
+
return "".join(ch for ch, _ in _scan_line(content)).rstrip(_YAML_WHITESPACE)
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _reject_non_printable(line: str) -> None:
|
|
1938
|
+
r"""Refuse the characters ``yaml.reader.Reader`` refuses, as it does.
|
|
1939
|
+
|
|
1940
|
+
PyYAML validates its raw stream before the scanner ever runs, so a
|
|
1941
|
+
non-printable character is an ``unacceptable character`` reader error
|
|
1942
|
+
wherever it sits -- in a comment, inside a quoted scalar, or in the middle
|
|
1943
|
+
of a plain one. Nothing below would have refused one: ``model: ab``
|
|
1944
|
+
was read here as the ordinary plain scalar ``ab``, and the five
|
|
1945
|
+
characters ``str.splitlines()`` breaks on but YAML does not (U+000B,
|
|
1946
|
+
U+000C, U+001C, U+001D, U+001E) turned one line into two, which is how
|
|
1947
|
+
``version: 1<U+000C>safety:\n allow_merge: true`` opened the merge gate
|
|
1948
|
+
on this backend against a file PyYAML refuses to load.
|
|
1949
|
+
|
|
1950
|
+
This is the reader's rule, which is why it is checked over the raw line
|
|
1951
|
+
and not through ``_scan_line``: unlike a tab, no quoting makes it legal.
|
|
1952
|
+
"""
|
|
1953
|
+
found = _YAML_NON_PRINTABLE_RE.search(line)
|
|
1954
|
+
if found is not None:
|
|
1955
|
+
raise _unsupported(f"unacceptable character #x{ord(found.group()):04x} at", line)
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
def _reject_tab(line: str) -> None:
|
|
1959
|
+
r"""Refuse a tab anywhere PyYAML's scanner refuses one.
|
|
1960
|
+
|
|
1961
|
+
PyYAML skips only *spaces* between tokens and ends a plain scalar at a
|
|
1962
|
+
tab, so a tab that is neither inside a quoted scalar nor inside a comment
|
|
1963
|
+
reaches its scanner as a character that "cannot start any token" -- tab
|
|
1964
|
+
indentation, ``model: foo\tbar`` and even a trailing ``model: foo\t`` are
|
|
1965
|
+
all scanner errors there. The line cleaning above strips and splits on
|
|
1966
|
+
spaces alone, so it would read every one of them as an ordinary plain
|
|
1967
|
+
scalar. A tab inside a quoted scalar or a comment is legal under PyYAML
|
|
1968
|
+
and read identically here, so only the rest is refused.
|
|
1969
|
+
|
|
1970
|
+
Which tab is inside a quoted scalar is ``_scan_line``'s decision, the
|
|
1971
|
+
same one ``_strip_inline_comment`` uses.
|
|
1972
|
+
"""
|
|
1973
|
+
for ch, quoted in _scan_line(line):
|
|
1974
|
+
if ch == "\t" and not quoted:
|
|
1975
|
+
raise _unsupported("tab outside a quoted scalar at", line)
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _split_key(content: str) -> tuple[str, str] | None:
|
|
1979
|
+
"""Split ``key: value`` at the colon that ends the key, or ``None``.
|
|
1980
|
+
|
|
1981
|
+
In block context a plain ``:`` ends a key only when a space or the end of
|
|
1982
|
+
the line follows it, so ``model: gpt`` is a mapping while ``url:port`` is
|
|
1983
|
+
one plain scalar and ``a:b: 1`` is the key ``a:b``. Splitting on the first
|
|
1984
|
+
colon instead read ``a:b: 1`` as the key ``a`` with the value ``b: 1``,
|
|
1985
|
+
which PyYAML never agreed with. A quoted key is scanned to its closing
|
|
1986
|
+
quote first, so ``"a: b": 1`` is one key too.
|
|
1987
|
+
"""
|
|
1988
|
+
i = 0
|
|
1989
|
+
if content[:1] in ("'", '"'):
|
|
1990
|
+
end = content.find(content[0], 1)
|
|
1991
|
+
if end < 0:
|
|
1992
|
+
return None
|
|
1993
|
+
i = end + 1
|
|
1994
|
+
while i < len(content):
|
|
1995
|
+
if content[i] == ":" and (i + 1 == len(content) or content[i + 1] == " "):
|
|
1996
|
+
return _yaml_strip(content[:i]), _yaml_strip(content[i + 1 :])
|
|
1997
|
+
i += 1
|
|
1998
|
+
return None
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _reject_plain_indicators(t: str, flow: bool) -> None:
|
|
2002
|
+
"""Refuse the plain scalars PyYAML's scanner refuses in this context.
|
|
2003
|
+
|
|
2004
|
+
PyYAML ends a plain scalar at a ``:`` that a space or the end of the line
|
|
2005
|
+
follows, and reads a leading ``- ``, ``? `` or ``: `` as the block
|
|
2006
|
+
indicator each one is, so it never builds the string these look like:
|
|
2007
|
+
``model: x: y`` is `mapping values are not allowed here` there, ``model:
|
|
2008
|
+
- x`` is `sequence entries are not allowed here` and ``model: ? x`` is
|
|
2009
|
+
`mapping keys are not allowed here`. ``_split_key`` already encodes the
|
|
2010
|
+
colon half of that rule for keys; this is the same rule for what remains
|
|
2011
|
+
as a value, so the string a plain scalar looks like is never invented
|
|
2012
|
+
where PyYAML refuses the file outright.
|
|
2013
|
+
|
|
2014
|
+
Inside a flow collection PyYAML also ends a plain scalar at ``?``
|
|
2015
|
+
wherever it appears (``[a?b]`` is a parser error there, while the block
|
|
2016
|
+
scalar ``a?b`` is an ordinary string).
|
|
2017
|
+
"""
|
|
2018
|
+
if t == "-" or t.startswith("- "):
|
|
2019
|
+
raise _unsupported("sequence entries are not allowed in a plain scalar at", t)
|
|
2020
|
+
if t == "?" or t.startswith("? ") or (flow and "?" in t):
|
|
2021
|
+
raise _unsupported("mapping keys are not allowed in a plain scalar at", t)
|
|
2022
|
+
if t.endswith(":") or ": " in t or (flow and t.startswith(":")):
|
|
2023
|
+
raise _unsupported("mapping values are not allowed in a plain scalar at", t)
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def _scalar(text: str, flow: bool = False) -> object:
|
|
2027
|
+
"""One YAML scalar, resolved exactly as PyYAML's implicit resolvers do.
|
|
2028
|
+
|
|
2029
|
+
Anything whose PyYAML reading this parser cannot reproduce is refused
|
|
2030
|
+
rather than guessed at (see the note above the resolvers). ``flow`` says
|
|
2031
|
+
whether the scalar is an item of a flow sequence, where PyYAML ends a
|
|
2032
|
+
plain scalar at more characters than in block context.
|
|
2033
|
+
"""
|
|
2034
|
+
t = _yaml_strip(text)
|
|
2035
|
+
if t[:1] in ("'", '"'):
|
|
2036
|
+
return _quoted_scalar(t)
|
|
2037
|
+
if t.startswith("["):
|
|
2038
|
+
return _flow_sequence(t)
|
|
2039
|
+
if t[:1] and (t[0] in _UNSUPPORTED_FIRST_CHARS or t in ("=", "<<")):
|
|
2040
|
+
raise _unsupported("unsupported YAML at", t)
|
|
2041
|
+
_reject_plain_indicators(t, flow)
|
|
2042
|
+
if t in _YAML_NULL:
|
|
2043
|
+
return None
|
|
2044
|
+
if t in _YAML_TRUE:
|
|
2045
|
+
return True
|
|
2046
|
+
if t in _YAML_FALSE:
|
|
2047
|
+
return False
|
|
2048
|
+
if _YAML_INT_RE.match(t):
|
|
2049
|
+
return _yaml_int(t)
|
|
2050
|
+
if _YAML_FLOAT_RE.match(t):
|
|
2051
|
+
return _yaml_float(t)
|
|
2052
|
+
if _YAML_TIMESTAMP_RE.match(t):
|
|
2053
|
+
raise _unsupported("YAML timestamps are not supported at", t)
|
|
2054
|
+
return t
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def _quoted_scalar(text: str) -> str:
|
|
2058
|
+
"""A single- or double-quoted scalar, without decoding any escape.
|
|
2059
|
+
|
|
2060
|
+
``''`` inside a single-quoted scalar and ``\\n`` inside a double-quoted one
|
|
2061
|
+
mean something to PyYAML that this parser would keep verbatim, so a quoted
|
|
2062
|
+
scalar containing either is refused instead of being read differently.
|
|
2063
|
+
"""
|
|
2064
|
+
quote = text[0]
|
|
2065
|
+
if len(text) < 2 or not text.endswith(quote):
|
|
2066
|
+
raise _unsupported("unterminated quoted string at", text)
|
|
2067
|
+
inner = text[1:-1]
|
|
2068
|
+
if quote in inner:
|
|
2069
|
+
raise _unsupported("quoted string with an embedded quote at", text)
|
|
2070
|
+
if quote == '"' and "\\" in inner:
|
|
2071
|
+
raise _unsupported("escape sequence in a double-quoted string at", text)
|
|
2072
|
+
return inner
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _flow_sequence(text: str) -> list[object]:
|
|
2076
|
+
"""An inline ``[a, b]`` list; its items are scalars like any other."""
|
|
2077
|
+
if not text.endswith("]"):
|
|
2078
|
+
raise _unsupported("unterminated flow sequence at", text)
|
|
2079
|
+
inner = _yaml_strip(text[1:-1])
|
|
2080
|
+
if not inner:
|
|
2081
|
+
return []
|
|
2082
|
+
return [_scalar(part, flow=True) for part in _split_inline(inner)]
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def _yaml_int(text: str) -> int:
|
|
2086
|
+
"""``yaml.constructor.SafeConstructor.construct_yaml_int``, reproduced.
|
|
2087
|
+
|
|
2088
|
+
YAML 1.1 has binary, octal (a leading ``0``), hexadecimal and sexagesimal
|
|
2089
|
+
integers, so ``0600`` is 384 and ``1:30`` is 90. Python's ``int()`` reads
|
|
2090
|
+
neither, which is where the two backends used to part ways.
|
|
2091
|
+
"""
|
|
2092
|
+
value = text.replace("_", "")
|
|
2093
|
+
sign = -1 if value[0] == "-" else 1
|
|
2094
|
+
if value[0] in "+-":
|
|
2095
|
+
value = value[1:]
|
|
2096
|
+
if value == "0":
|
|
2097
|
+
return 0
|
|
2098
|
+
if value.startswith("0b"):
|
|
2099
|
+
return sign * int(value[2:], 2)
|
|
2100
|
+
if value.startswith("0x"):
|
|
2101
|
+
return sign * int(value[2:], 16)
|
|
2102
|
+
if value[0] == "0":
|
|
2103
|
+
return sign * int(value, 8)
|
|
2104
|
+
if ":" in value:
|
|
2105
|
+
digits = [int(part) for part in value.split(":")]
|
|
2106
|
+
digits.reverse()
|
|
2107
|
+
total, base = 0, 1
|
|
2108
|
+
for digit in digits:
|
|
2109
|
+
total += digit * base
|
|
2110
|
+
base *= 60
|
|
2111
|
+
return sign * total
|
|
2112
|
+
return sign * int(value)
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
def _yaml_float(text: str) -> float:
|
|
2116
|
+
"""``yaml.constructor.SafeConstructor.construct_yaml_float``, reproduced."""
|
|
2117
|
+
value = text.replace("_", "").lower()
|
|
2118
|
+
sign = -1 if value[0] == "-" else 1
|
|
2119
|
+
if value[0] in "+-":
|
|
2120
|
+
value = value[1:]
|
|
2121
|
+
if value == ".inf":
|
|
2122
|
+
return sign * float("inf")
|
|
2123
|
+
if value == ".nan":
|
|
2124
|
+
return float("nan")
|
|
2125
|
+
if ":" in value:
|
|
2126
|
+
digits = [float(part) for part in value.split(":")]
|
|
2127
|
+
digits.reverse()
|
|
2128
|
+
total, base = 0.0, 1
|
|
2129
|
+
for digit in digits:
|
|
2130
|
+
total += digit * base
|
|
2131
|
+
base *= 60
|
|
2132
|
+
return sign * total
|
|
2133
|
+
return sign * float(value)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def _split_inline(inner: str) -> list[str]:
|
|
2137
|
+
"""Split a flow sequence's items on the commas outside its quoted scalars.
|
|
2138
|
+
|
|
2139
|
+
A nested flow collection is refused rather than split on the wrong comma:
|
|
2140
|
+
``[[1, 2]]`` is two malformed items to this split and one list to PyYAML.
|
|
2141
|
+
|
|
2142
|
+
A quote opens a quoted scalar only where an item may begin, which is
|
|
2143
|
+
``_scan_line``'s rule one level down: ``[a 'b, c']`` is the two plain
|
|
2144
|
+
scalars ``a 'b`` and ``c'`` to PyYAML, not one quoted item, and a quote
|
|
2145
|
+
inside a plain item must not hide the ``]`` after it either.
|
|
2146
|
+
"""
|
|
2147
|
+
parts: list[str] = []
|
|
2148
|
+
cur = ""
|
|
2149
|
+
quote = ""
|
|
2150
|
+
at_start = True
|
|
2151
|
+
for ch in inner:
|
|
2152
|
+
if quote:
|
|
2153
|
+
if ch == quote:
|
|
2154
|
+
quote = ""
|
|
2155
|
+
at_start = False
|
|
2156
|
+
cur += ch
|
|
2157
|
+
continue
|
|
2158
|
+
if ch in "'\"" and at_start:
|
|
2159
|
+
quote = ch
|
|
2160
|
+
cur += ch
|
|
2161
|
+
continue
|
|
2162
|
+
if ch in "[]{}":
|
|
2163
|
+
raise _unsupported("nested flow collection at", inner)
|
|
2164
|
+
if ch == ",":
|
|
2165
|
+
parts.append(_yaml_strip(cur))
|
|
2166
|
+
cur = ""
|
|
2167
|
+
at_start = True
|
|
2168
|
+
continue
|
|
2169
|
+
if ch not in " \t":
|
|
2170
|
+
at_start = False
|
|
2171
|
+
cur += ch
|
|
2172
|
+
if quote:
|
|
2173
|
+
raise _unsupported("unterminated quoted string at", inner)
|
|
2174
|
+
if _yaml_strip(cur):
|
|
2175
|
+
parts.append(_yaml_strip(cur))
|
|
2176
|
+
if not all(parts):
|
|
2177
|
+
raise _unsupported("empty item in a flow sequence at", inner)
|
|
2178
|
+
return parts
|