delegate-agent-cli 0.11.0__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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
delegate_agent/config.py
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from delegate_agent import reasoning, redaction, wsl
|
|
11
|
+
from delegate_agent.json_types import JsonObject, JsonValue, is_non_negative_int
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_PATH: Path | None = None
|
|
14
|
+
DEFAULT_CONFIG_RELATIVE: Final = Path(".delegate") / "config.json"
|
|
15
|
+
WORKSPACE_CONFIG_RELATIVE = Path(".delegate") / "config.json"
|
|
16
|
+
CONFIG_ENV = "DELEGATE_CONFIG"
|
|
17
|
+
COMPLETION_REPORT_MODE_MARKDOWN = "markdown"
|
|
18
|
+
COMPLETION_REPORT_MODE_NONE = "none"
|
|
19
|
+
COMPLETION_REPORT_MODES = (
|
|
20
|
+
COMPLETION_REPORT_MODE_MARKDOWN,
|
|
21
|
+
COMPLETION_REPORT_MODE_NONE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ISOLATION_AUTO = "auto"
|
|
25
|
+
ISOLATION_NONE = "none"
|
|
26
|
+
ISOLATION_WORKTREE = "worktree"
|
|
27
|
+
VALID_ISOLATION_VALUES = (ISOLATION_AUTO, ISOLATION_NONE, ISOLATION_WORKTREE)
|
|
28
|
+
SAFE_ISOLATION_REQUIRED_ENGINES = frozenset({"cursor", "droid", "kimi", "claude", "grok"})
|
|
29
|
+
|
|
30
|
+
POLICY_PROFILES = ("safe", "trusted-hooks", "external-sandbox", "custom")
|
|
31
|
+
POLICY_MODE_KEYS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"networkAccess",
|
|
34
|
+
"webSearch",
|
|
35
|
+
"bypassApprovalsAndSandbox",
|
|
36
|
+
"bypassHookTrust",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
# Bypass flags are escalations that only make sense for edit-capable work runs.
|
|
40
|
+
# Safe mode is read-only by promise, so reject them in any safe-mode policy block.
|
|
41
|
+
SAFE_FORBIDDEN_BYPASS_KEYS = ("bypassApprovalsAndSandbox", "bypassHookTrust")
|
|
42
|
+
CODEX_WORK_SANDBOX_VALUES = ("read-only", "workspace-write", "danger-full-access")
|
|
43
|
+
CLAUDE_WORK_PERMISSION_MODES = ("acceptEdits", "auto", "default", "dontAsk", "plan")
|
|
44
|
+
GROK_PERMISSION_MODES = CLAUDE_WORK_PERMISSION_MODES
|
|
45
|
+
GROK_BYPASS_PERMISSION_MODE = "bypassPermissions"
|
|
46
|
+
GROK_SAFE_SANDBOX_VALUES = ("read-only", "strict")
|
|
47
|
+
GROK_WORK_SANDBOX_VALUES = ("workspace", "devbox", "read-only", "strict")
|
|
48
|
+
|
|
49
|
+
DEFAULT_MODE_POLICY: JsonObject = {
|
|
50
|
+
"networkAccess": False,
|
|
51
|
+
"webSearch": False,
|
|
52
|
+
"bypassApprovalsAndSandbox": False,
|
|
53
|
+
"bypassHookTrust": False,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_EMBEDDED_DEFAULT_CONFIG: JsonObject = {
|
|
57
|
+
"tracking": {
|
|
58
|
+
"completionReport": {
|
|
59
|
+
"defaultMode": COMPLETION_REPORT_MODE_MARKDOWN,
|
|
60
|
+
},
|
|
61
|
+
"retention": {
|
|
62
|
+
"enabled": True,
|
|
63
|
+
"rawLogDays": 7,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
"cursor": {
|
|
67
|
+
"argvPrefix": ["agent"],
|
|
68
|
+
"defaultModel": "composer-2.5",
|
|
69
|
+
"defaultReasoningEffort": None,
|
|
70
|
+
"reasoningEffortModels": {},
|
|
71
|
+
},
|
|
72
|
+
"droid": {
|
|
73
|
+
"binary": "droid",
|
|
74
|
+
"models": {},
|
|
75
|
+
"defaultReasoningEffort": None,
|
|
76
|
+
},
|
|
77
|
+
"reasoning": {
|
|
78
|
+
"capabilities": {},
|
|
79
|
+
},
|
|
80
|
+
"kimi": {
|
|
81
|
+
"binary": "kimi",
|
|
82
|
+
"defaultModel": "kimi-code/kimi-for-coding",
|
|
83
|
+
"defaultReasoningEffort": None,
|
|
84
|
+
},
|
|
85
|
+
"claude": {
|
|
86
|
+
"binary": "claude",
|
|
87
|
+
"defaultModel": None,
|
|
88
|
+
"defaultReasoningEffort": None,
|
|
89
|
+
"workPermissionMode": "auto",
|
|
90
|
+
"noSessionPersistence": True,
|
|
91
|
+
"bare": False,
|
|
92
|
+
},
|
|
93
|
+
"grok": {
|
|
94
|
+
"binary": "grok",
|
|
95
|
+
"defaultModel": None,
|
|
96
|
+
"defaultReasoningEffort": None,
|
|
97
|
+
"workPermissionMode": "auto",
|
|
98
|
+
"safePermissionMode": "dontAsk",
|
|
99
|
+
"safeSandbox": "read-only",
|
|
100
|
+
"workSandbox": None,
|
|
101
|
+
"disableWebSearch": True,
|
|
102
|
+
"noSubagents": False,
|
|
103
|
+
},
|
|
104
|
+
"policy": {
|
|
105
|
+
"profile": "safe",
|
|
106
|
+
"work": {
|
|
107
|
+
"networkAccess": True,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
"codex": {
|
|
111
|
+
"binary": "codex",
|
|
112
|
+
"defaultModel": None,
|
|
113
|
+
"defaultReasoningEffort": None,
|
|
114
|
+
"profile": None,
|
|
115
|
+
"fallbackProfile": None,
|
|
116
|
+
"workSandbox": "workspace-write",
|
|
117
|
+
"ephemeral": True,
|
|
118
|
+
"ignoreUserConfig": False,
|
|
119
|
+
},
|
|
120
|
+
"profiles": {
|
|
121
|
+
"detectFrom": ["DELEGATE_PROFILE", "AI_PROFILE"],
|
|
122
|
+
"default": None,
|
|
123
|
+
"definitions": {},
|
|
124
|
+
},
|
|
125
|
+
"isolation": {
|
|
126
|
+
"safe": ISOLATION_AUTO,
|
|
127
|
+
"work": ISOLATION_NONE,
|
|
128
|
+
},
|
|
129
|
+
"worktrees": {
|
|
130
|
+
"dataHome": None,
|
|
131
|
+
"autoPrune": {
|
|
132
|
+
"enabled": False,
|
|
133
|
+
"mergedOlderThanDays": 7,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
"progress": {
|
|
137
|
+
"enabled": False,
|
|
138
|
+
"initialDelaySec": 30,
|
|
139
|
+
"intervalSec": 60,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def embedded_default_config() -> JsonObject:
|
|
145
|
+
"""Return a fresh copy of Delegate's embedded default configuration."""
|
|
146
|
+
return copy.deepcopy(_EMBEDDED_DEFAULT_CONFIG)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def example_config() -> JsonObject:
|
|
150
|
+
"""Return an editable starter config equivalent to config.example.json."""
|
|
151
|
+
|
|
152
|
+
config = embedded_default_config()
|
|
153
|
+
droid = config["droid"]
|
|
154
|
+
if isinstance(droid, dict):
|
|
155
|
+
droid["models"] = {
|
|
156
|
+
"reviewer": "replace-with-read-only-model-id",
|
|
157
|
+
"implementer": "replace-with-edit-capable-model-id",
|
|
158
|
+
}
|
|
159
|
+
profiles = config["profiles"]
|
|
160
|
+
if isinstance(profiles, dict):
|
|
161
|
+
profiles["definitions"] = {
|
|
162
|
+
"work": {"env": {"CODEX_HOME": "~/replace-with-work-codex-home"}},
|
|
163
|
+
"personal": {"env": {"CODEX_HOME": "~/replace-with-personal-codex-home"}},
|
|
164
|
+
}
|
|
165
|
+
return config
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _embedded_progress_default(key: str) -> float:
|
|
169
|
+
progress = _EMBEDDED_DEFAULT_CONFIG["progress"]
|
|
170
|
+
if not isinstance(progress, dict):
|
|
171
|
+
raise AssertionError("embedded progress defaults must be an object")
|
|
172
|
+
value = progress[key]
|
|
173
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
174
|
+
raise AssertionError(f"embedded progress.{key} must be numeric")
|
|
175
|
+
return float(value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def default_progress_initial_delay_sec() -> float:
|
|
179
|
+
return _embedded_progress_default("initialDelaySec")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def default_progress_interval_sec() -> float:
|
|
183
|
+
return _embedded_progress_default("intervalSec")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
DEFAULT_CONFIG: JsonObject = embedded_default_config()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _profile_policy(profile: str) -> JsonObject:
|
|
190
|
+
if profile == "trusted-hooks":
|
|
191
|
+
return {"work": {"bypassHookTrust": True}}
|
|
192
|
+
if profile == "external-sandbox":
|
|
193
|
+
return {
|
|
194
|
+
"work": {
|
|
195
|
+
"bypassApprovalsAndSandbox": True,
|
|
196
|
+
"bypassHookTrust": True,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def effective_policy(config: JsonObject, *, engine: str, mode: str) -> JsonObject:
|
|
203
|
+
policy = config.get("policy", {})
|
|
204
|
+
if not isinstance(policy, dict):
|
|
205
|
+
policy = {}
|
|
206
|
+
profile = policy.get("profile", "safe")
|
|
207
|
+
profile_defaults = _profile_policy(profile if isinstance(profile, str) else "safe")
|
|
208
|
+
profile_mode = profile_defaults.get(mode)
|
|
209
|
+
mode_policy = deep_merge(
|
|
210
|
+
DEFAULT_MODE_POLICY,
|
|
211
|
+
profile_mode if isinstance(profile_mode, dict) else {},
|
|
212
|
+
)
|
|
213
|
+
explicit_mode = policy.get(mode)
|
|
214
|
+
if isinstance(explicit_mode, dict):
|
|
215
|
+
mode_policy = deep_merge(mode_policy, explicit_mode)
|
|
216
|
+
harness = policy.get("harness")
|
|
217
|
+
if isinstance(harness, dict):
|
|
218
|
+
engine_policy = harness.get(engine)
|
|
219
|
+
if isinstance(engine_policy, dict):
|
|
220
|
+
mode_override = engine_policy.get(mode)
|
|
221
|
+
if isinstance(mode_override, dict):
|
|
222
|
+
mode_policy = deep_merge(mode_policy, mode_override)
|
|
223
|
+
return mode_policy
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_policy_mode_policy(mode_policy: JsonObject, path: str, *, mode: str) -> None:
|
|
227
|
+
if not isinstance(mode_policy, dict):
|
|
228
|
+
raise ConfigError("invalid_policy_config", f"{path} must be an object.")
|
|
229
|
+
unknown = set(mode_policy) - POLICY_MODE_KEYS
|
|
230
|
+
if unknown:
|
|
231
|
+
raise ConfigError(
|
|
232
|
+
"invalid_policy_config",
|
|
233
|
+
f"{path} has unknown keys: {', '.join(sorted(unknown))}.",
|
|
234
|
+
)
|
|
235
|
+
for key in POLICY_MODE_KEYS:
|
|
236
|
+
value = mode_policy.get(key)
|
|
237
|
+
if value is not None and not isinstance(value, bool):
|
|
238
|
+
raise ConfigError(
|
|
239
|
+
"invalid_policy_config",
|
|
240
|
+
f"{path}.{key} must be a boolean.",
|
|
241
|
+
)
|
|
242
|
+
if mode == "safe":
|
|
243
|
+
for key in SAFE_FORBIDDEN_BYPASS_KEYS:
|
|
244
|
+
if mode_policy.get(key) is True:
|
|
245
|
+
raise ConfigError(
|
|
246
|
+
"invalid_policy_config",
|
|
247
|
+
f"{path}.{key} cannot be enabled in safe mode; safe mode is read-only.",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _validate_policy_section(policy: JsonValue, *, path: str = "policy") -> None:
|
|
252
|
+
if policy is None:
|
|
253
|
+
return
|
|
254
|
+
if not isinstance(policy, dict):
|
|
255
|
+
raise ConfigError("invalid_policy_config", f"{path} must be an object.")
|
|
256
|
+
profile = policy.get("profile", "safe")
|
|
257
|
+
if profile not in POLICY_PROFILES:
|
|
258
|
+
raise ConfigError(
|
|
259
|
+
"invalid_policy_config",
|
|
260
|
+
f"{path}.profile must be one of: {', '.join(POLICY_PROFILES)}.",
|
|
261
|
+
)
|
|
262
|
+
for mode in ("safe", "work"):
|
|
263
|
+
mode_policy = policy.get(mode)
|
|
264
|
+
if mode_policy is not None:
|
|
265
|
+
_validate_policy_mode_policy(mode_policy, f"{path}.{mode}", mode=mode)
|
|
266
|
+
harness = policy.get("harness")
|
|
267
|
+
if harness is not None:
|
|
268
|
+
if not isinstance(harness, dict):
|
|
269
|
+
raise ConfigError("invalid_policy_config", f"{path}.harness must be an object.")
|
|
270
|
+
for engine, engine_policy in harness.items():
|
|
271
|
+
if not isinstance(engine, str) or not engine:
|
|
272
|
+
raise ConfigError(
|
|
273
|
+
"invalid_policy_config",
|
|
274
|
+
f"{path}.harness engine names must be non-empty strings.",
|
|
275
|
+
)
|
|
276
|
+
if not isinstance(engine_policy, dict):
|
|
277
|
+
raise ConfigError(
|
|
278
|
+
"invalid_policy_config",
|
|
279
|
+
f"{path}.harness.{engine} must be an object.",
|
|
280
|
+
)
|
|
281
|
+
for mode in ("safe", "work"):
|
|
282
|
+
mode_policy = engine_policy.get(mode)
|
|
283
|
+
if mode_policy is not None:
|
|
284
|
+
_validate_policy_mode_policy(
|
|
285
|
+
mode_policy,
|
|
286
|
+
f"{path}.harness.{engine}.{mode}",
|
|
287
|
+
mode=mode,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _validate_isolation_section(isolation: JsonValue) -> None:
|
|
292
|
+
if isolation is None:
|
|
293
|
+
return
|
|
294
|
+
if not isinstance(isolation, dict):
|
|
295
|
+
raise ConfigError(
|
|
296
|
+
"invalid_isolation_config",
|
|
297
|
+
"isolation config must be an object.",
|
|
298
|
+
)
|
|
299
|
+
for mode in ("safe", "work"):
|
|
300
|
+
if mode not in isolation:
|
|
301
|
+
continue
|
|
302
|
+
value = isolation[mode]
|
|
303
|
+
if value is None:
|
|
304
|
+
raise ConfigError(
|
|
305
|
+
"invalid_isolation_config",
|
|
306
|
+
f"isolation.{mode} must not be null; use one of: {', '.join(VALID_ISOLATION_VALUES)}.",
|
|
307
|
+
)
|
|
308
|
+
if value not in VALID_ISOLATION_VALUES:
|
|
309
|
+
raise ConfigError(
|
|
310
|
+
"invalid_isolation_config",
|
|
311
|
+
f"isolation.{mode} must be one of: {', '.join(VALID_ISOLATION_VALUES)}.",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _validate_required_non_negative_int(
|
|
316
|
+
value: JsonValue,
|
|
317
|
+
*,
|
|
318
|
+
path: str,
|
|
319
|
+
error: str,
|
|
320
|
+
) -> None:
|
|
321
|
+
if value is None:
|
|
322
|
+
raise ConfigError(error, f"{path} must not be null.")
|
|
323
|
+
if not is_non_negative_int(value):
|
|
324
|
+
raise ConfigError(error, f"{path} must be a non-negative integer.")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_progress_section(progress: JsonValue) -> None:
|
|
328
|
+
if progress is None:
|
|
329
|
+
return
|
|
330
|
+
if not isinstance(progress, dict):
|
|
331
|
+
raise ConfigError("invalid_progress_config", "progress config must be an object.")
|
|
332
|
+
if "enabled" in progress:
|
|
333
|
+
enabled = progress["enabled"]
|
|
334
|
+
if not isinstance(enabled, bool):
|
|
335
|
+
raise ConfigError(
|
|
336
|
+
"invalid_progress_config",
|
|
337
|
+
"progress.enabled must be a boolean.",
|
|
338
|
+
)
|
|
339
|
+
for key in ("initialDelaySec", "intervalSec"):
|
|
340
|
+
if key not in progress:
|
|
341
|
+
continue
|
|
342
|
+
value = progress[key]
|
|
343
|
+
if (
|
|
344
|
+
isinstance(value, bool)
|
|
345
|
+
or not isinstance(value, (int, float))
|
|
346
|
+
or not math.isfinite(value)
|
|
347
|
+
or value <= 0
|
|
348
|
+
):
|
|
349
|
+
raise ConfigError(
|
|
350
|
+
"invalid_progress_config",
|
|
351
|
+
f"progress.{key} must be a positive number.",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _validate_worktrees_section(worktrees: JsonValue) -> None:
|
|
356
|
+
if worktrees is None:
|
|
357
|
+
return
|
|
358
|
+
if not isinstance(worktrees, dict):
|
|
359
|
+
raise ConfigError(
|
|
360
|
+
"invalid_worktrees_config",
|
|
361
|
+
"worktrees config must be an object.",
|
|
362
|
+
)
|
|
363
|
+
data_home = worktrees.get("dataHome")
|
|
364
|
+
if data_home is not None and not (isinstance(data_home, str) and data_home):
|
|
365
|
+
raise ConfigError(
|
|
366
|
+
"invalid_worktrees_config",
|
|
367
|
+
"worktrees.dataHome must be null or a non-empty string.",
|
|
368
|
+
)
|
|
369
|
+
if isinstance(data_home, str) and data_home:
|
|
370
|
+
if wsl.should_reject_windows_path(data_home):
|
|
371
|
+
raise ConfigError(
|
|
372
|
+
"windows_path",
|
|
373
|
+
wsl.windows_path_message("worktrees.dataHome", data_home),
|
|
374
|
+
)
|
|
375
|
+
expanded = Path(data_home).expanduser()
|
|
376
|
+
if not expanded.is_absolute():
|
|
377
|
+
raise ConfigError(
|
|
378
|
+
"invalid_worktrees_config",
|
|
379
|
+
"worktrees.dataHome must be an absolute path or start with ~/.",
|
|
380
|
+
)
|
|
381
|
+
auto_prune = worktrees.get("autoPrune")
|
|
382
|
+
if auto_prune is not None:
|
|
383
|
+
if not isinstance(auto_prune, dict):
|
|
384
|
+
raise ConfigError(
|
|
385
|
+
"invalid_worktrees_config",
|
|
386
|
+
"worktrees.autoPrune must be an object.",
|
|
387
|
+
)
|
|
388
|
+
if "enabled" in auto_prune:
|
|
389
|
+
enabled = auto_prune["enabled"]
|
|
390
|
+
if enabled is None:
|
|
391
|
+
raise ConfigError(
|
|
392
|
+
"invalid_worktrees_config",
|
|
393
|
+
"worktrees.autoPrune.enabled must not be null.",
|
|
394
|
+
)
|
|
395
|
+
if not isinstance(enabled, bool):
|
|
396
|
+
raise ConfigError(
|
|
397
|
+
"invalid_worktrees_config",
|
|
398
|
+
"worktrees.autoPrune.enabled must be a boolean.",
|
|
399
|
+
)
|
|
400
|
+
if "mergedOlderThanDays" in auto_prune:
|
|
401
|
+
_validate_required_non_negative_int(
|
|
402
|
+
auto_prune["mergedOlderThanDays"],
|
|
403
|
+
path="worktrees.autoPrune.mergedOlderThanDays",
|
|
404
|
+
error="invalid_worktrees_config",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _profiles_definitions(profiles: JsonValue) -> dict[str, JsonObject]:
|
|
409
|
+
if not isinstance(profiles, dict):
|
|
410
|
+
return {}
|
|
411
|
+
definitions = profiles.get("definitions")
|
|
412
|
+
if not isinstance(definitions, dict):
|
|
413
|
+
return {}
|
|
414
|
+
return {
|
|
415
|
+
name.strip(): entry
|
|
416
|
+
for name, entry in definitions.items()
|
|
417
|
+
if isinstance(name, str) and name.strip() and isinstance(entry, dict)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _validate_profiles_section(profiles: JsonValue) -> None:
|
|
422
|
+
if profiles is None:
|
|
423
|
+
return
|
|
424
|
+
if not isinstance(profiles, dict):
|
|
425
|
+
raise ConfigError("invalid_profiles_config", "profiles config must be an object.")
|
|
426
|
+
detect_from = profiles.get("detectFrom", [])
|
|
427
|
+
if not isinstance(detect_from, list) or not all(
|
|
428
|
+
isinstance(item, str) and item.strip() for item in detect_from
|
|
429
|
+
):
|
|
430
|
+
raise ConfigError(
|
|
431
|
+
"invalid_profiles_config",
|
|
432
|
+
"profiles.detectFrom must be an array of non-empty strings.",
|
|
433
|
+
)
|
|
434
|
+
definitions = profiles.get("definitions", {})
|
|
435
|
+
if not isinstance(definitions, dict):
|
|
436
|
+
raise ConfigError("invalid_profiles_config", "profiles.definitions must be an object.")
|
|
437
|
+
normalized_names: set[str] = set()
|
|
438
|
+
for name, entry in definitions.items():
|
|
439
|
+
if not isinstance(name, str) or not name.strip():
|
|
440
|
+
raise ConfigError(
|
|
441
|
+
"invalid_profiles_config",
|
|
442
|
+
"profiles.definitions keys must be non-empty strings.",
|
|
443
|
+
)
|
|
444
|
+
normalized_name = name.strip()
|
|
445
|
+
normalized_names.add(normalized_name)
|
|
446
|
+
if not isinstance(entry, dict):
|
|
447
|
+
raise ConfigError(
|
|
448
|
+
"invalid_profiles_config",
|
|
449
|
+
f"profiles.definitions.{normalized_name} must be an object.",
|
|
450
|
+
)
|
|
451
|
+
unknown = set(entry) - {"env"}
|
|
452
|
+
if unknown:
|
|
453
|
+
raise ConfigError(
|
|
454
|
+
"invalid_profiles_config",
|
|
455
|
+
f"profiles.definitions.{normalized_name} has unknown keys: "
|
|
456
|
+
f"{', '.join(sorted(unknown))}.",
|
|
457
|
+
)
|
|
458
|
+
env = entry.get("env", {})
|
|
459
|
+
if not isinstance(env, dict):
|
|
460
|
+
raise ConfigError(
|
|
461
|
+
"invalid_profiles_config",
|
|
462
|
+
f"profiles.definitions.{normalized_name}.env must be an object.",
|
|
463
|
+
)
|
|
464
|
+
for key, value in env.items():
|
|
465
|
+
path = f"profiles.definitions.{normalized_name}.env.{key}"
|
|
466
|
+
if not isinstance(key, str) or not key.strip():
|
|
467
|
+
raise ConfigError(
|
|
468
|
+
"invalid_profiles_config",
|
|
469
|
+
f"profiles.definitions.{normalized_name}.env keys must be non-empty strings.",
|
|
470
|
+
)
|
|
471
|
+
if redaction.key_looks_secret(key):
|
|
472
|
+
raise ConfigError(
|
|
473
|
+
"secret_in_profile_env",
|
|
474
|
+
f"{path} looks like a secret. Export secrets in the shell; Phase-2 envFile "
|
|
475
|
+
"will support secret files.",
|
|
476
|
+
)
|
|
477
|
+
if not isinstance(value, str) or not value.strip():
|
|
478
|
+
raise ConfigError(
|
|
479
|
+
"invalid_profiles_config",
|
|
480
|
+
f"{path} must be a non-empty string.",
|
|
481
|
+
)
|
|
482
|
+
if key.strip() == "CODEX_HOME" and wsl.should_reject_windows_path(value):
|
|
483
|
+
raise ConfigError("windows_path", wsl.windows_path_message(path, value))
|
|
484
|
+
default = profiles.get("default")
|
|
485
|
+
if default is not None and (not isinstance(default, str) or default not in normalized_names):
|
|
486
|
+
raise ConfigError(
|
|
487
|
+
"invalid_profiles_config",
|
|
488
|
+
"profiles.default must be null or a defined profile name.",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _validate_codex_section(codex: JsonValue) -> None:
|
|
493
|
+
if not isinstance(codex, dict):
|
|
494
|
+
raise ConfigError("invalid_codex_config", "codex config must be an object.")
|
|
495
|
+
require_non_empty_str(codex.get("binary"), path="codex.binary", error="invalid_codex_config")
|
|
496
|
+
optional_str(codex.get("defaultModel"), path="codex.defaultModel", error="invalid_codex_config")
|
|
497
|
+
_validate_provider_default_reasoning_effort(
|
|
498
|
+
codex.get("defaultReasoningEffort"),
|
|
499
|
+
path="codex.defaultReasoningEffort",
|
|
500
|
+
error="invalid_codex_config",
|
|
501
|
+
)
|
|
502
|
+
optional_str(codex.get("profile"), path="codex.profile", error="invalid_codex_config")
|
|
503
|
+
optional_str(
|
|
504
|
+
codex.get("fallbackProfile"), path="codex.fallbackProfile", error="invalid_codex_config"
|
|
505
|
+
)
|
|
506
|
+
work_sandbox = codex.get("workSandbox", "workspace-write")
|
|
507
|
+
if work_sandbox not in CODEX_WORK_SANDBOX_VALUES:
|
|
508
|
+
raise ConfigError(
|
|
509
|
+
"invalid_codex_config",
|
|
510
|
+
"codex.workSandbox must be read-only, workspace-write, or danger-full-access.",
|
|
511
|
+
)
|
|
512
|
+
if "safeSandbox" in codex:
|
|
513
|
+
raise ConfigError(
|
|
514
|
+
"invalid_codex_config",
|
|
515
|
+
"codex.safeSandbox is not supported; Codex safe always uses read-only.",
|
|
516
|
+
)
|
|
517
|
+
require_bool(codex.get("ephemeral", True), path="codex.ephemeral", error="invalid_codex_config")
|
|
518
|
+
require_bool(
|
|
519
|
+
codex.get("ignoreUserConfig", False),
|
|
520
|
+
path="codex.ignoreUserConfig",
|
|
521
|
+
error="invalid_codex_config",
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _validate_codex_profile_references(config: JsonObject) -> None:
|
|
526
|
+
codex = config.get("codex")
|
|
527
|
+
if not isinstance(codex, dict):
|
|
528
|
+
return
|
|
529
|
+
fallback = codex.get("fallbackProfile")
|
|
530
|
+
if fallback is None:
|
|
531
|
+
return
|
|
532
|
+
if not isinstance(fallback, str) or not fallback.strip():
|
|
533
|
+
return
|
|
534
|
+
fallback = fallback.strip()
|
|
535
|
+
definitions = _profiles_definitions(config.get("profiles"))
|
|
536
|
+
profile = definitions.get(fallback)
|
|
537
|
+
if profile is None:
|
|
538
|
+
raise ConfigError(
|
|
539
|
+
"invalid_codex_config",
|
|
540
|
+
f"codex.fallbackProfile {fallback!r} is not defined in profiles.definitions.",
|
|
541
|
+
)
|
|
542
|
+
env = profile.get("env")
|
|
543
|
+
if (
|
|
544
|
+
not isinstance(env, dict)
|
|
545
|
+
or not isinstance(env.get("CODEX_HOME"), str)
|
|
546
|
+
or not env["CODEX_HOME"].strip()
|
|
547
|
+
):
|
|
548
|
+
raise ConfigError(
|
|
549
|
+
"profile_missing_codex_home",
|
|
550
|
+
f"codex.fallbackProfile {fallback!r} must define profiles.definitions.{fallback}.env.CODEX_HOME.",
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _validate_kimi_section(kimi: JsonValue) -> None:
|
|
555
|
+
if not isinstance(kimi, dict):
|
|
556
|
+
raise ConfigError("invalid_kimi_config", "kimi config must be an object.")
|
|
557
|
+
require_non_empty_str(kimi.get("binary"), path="kimi.binary", error="invalid_kimi_config")
|
|
558
|
+
optional_str(kimi.get("defaultModel"), path="kimi.defaultModel", error="invalid_kimi_config")
|
|
559
|
+
if kimi.get("defaultReasoningEffort") is not None:
|
|
560
|
+
raise ConfigError(
|
|
561
|
+
"invalid_kimi_config",
|
|
562
|
+
"kimi.defaultReasoningEffort is not supported; set it to null.",
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _validate_claude_section(claude: JsonValue) -> None:
|
|
567
|
+
if not isinstance(claude, dict):
|
|
568
|
+
raise ConfigError("invalid_claude_config", "claude config must be an object.")
|
|
569
|
+
require_non_empty_str(claude.get("binary"), path="claude.binary", error="invalid_claude_config")
|
|
570
|
+
optional_str(
|
|
571
|
+
claude.get("defaultModel"), path="claude.defaultModel", error="invalid_claude_config"
|
|
572
|
+
)
|
|
573
|
+
default_effort = claude.get("defaultReasoningEffort")
|
|
574
|
+
if default_effort is not None:
|
|
575
|
+
if not isinstance(default_effort, str):
|
|
576
|
+
raise ConfigError(
|
|
577
|
+
"invalid_claude_config",
|
|
578
|
+
"claude.defaultReasoningEffort must be a string or null.",
|
|
579
|
+
)
|
|
580
|
+
try:
|
|
581
|
+
reasoning.resolve_claude_native_effort(default_effort)
|
|
582
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
583
|
+
raise ConfigError(
|
|
584
|
+
"invalid_claude_config",
|
|
585
|
+
f"claude.defaultReasoningEffort: {exc.message}",
|
|
586
|
+
) from exc
|
|
587
|
+
permission_mode = claude.get("workPermissionMode", "auto")
|
|
588
|
+
if permission_mode == "bypassPermissions":
|
|
589
|
+
raise ConfigError(
|
|
590
|
+
"invalid_claude_config",
|
|
591
|
+
"claude.workPermissionMode cannot be bypassPermissions; "
|
|
592
|
+
"use policy.harness.claude.work.bypassApprovalsAndSandbox "
|
|
593
|
+
"for explicit Delegate-controlled bypass.",
|
|
594
|
+
)
|
|
595
|
+
if permission_mode not in CLAUDE_WORK_PERMISSION_MODES:
|
|
596
|
+
raise ConfigError(
|
|
597
|
+
"invalid_claude_config",
|
|
598
|
+
f"claude.workPermissionMode must be one of: {', '.join(CLAUDE_WORK_PERMISSION_MODES)}.",
|
|
599
|
+
)
|
|
600
|
+
require_bool(
|
|
601
|
+
claude.get("noSessionPersistence", True),
|
|
602
|
+
path="claude.noSessionPersistence",
|
|
603
|
+
error="invalid_claude_config",
|
|
604
|
+
)
|
|
605
|
+
require_bool(claude.get("bare", False), path="claude.bare", error="invalid_claude_config")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _validate_grok_section(grok: JsonValue) -> None:
|
|
609
|
+
if not isinstance(grok, dict):
|
|
610
|
+
raise ConfigError("invalid_grok_config", "grok config must be an object.")
|
|
611
|
+
require_non_empty_str(grok.get("binary"), path="grok.binary", error="invalid_grok_config")
|
|
612
|
+
optional_str(grok.get("defaultModel"), path="grok.defaultModel", error="invalid_grok_config")
|
|
613
|
+
default_effort = grok.get("defaultReasoningEffort")
|
|
614
|
+
if default_effort is not None:
|
|
615
|
+
if not isinstance(default_effort, str):
|
|
616
|
+
raise ConfigError(
|
|
617
|
+
"invalid_grok_config",
|
|
618
|
+
"grok.defaultReasoningEffort must be a string or null.",
|
|
619
|
+
)
|
|
620
|
+
try:
|
|
621
|
+
reasoning.resolve_grok_native_effort(default_effort)
|
|
622
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
623
|
+
raise ConfigError(
|
|
624
|
+
"invalid_grok_config",
|
|
625
|
+
f"grok.defaultReasoningEffort: {exc.message}",
|
|
626
|
+
) from exc
|
|
627
|
+
safe_permission = grok.get("safePermissionMode", "dontAsk")
|
|
628
|
+
if safe_permission == "plan":
|
|
629
|
+
raise ConfigError(
|
|
630
|
+
"invalid_grok_config",
|
|
631
|
+
"grok.safePermissionMode cannot be plan; Delegate safe mode uses isolated "
|
|
632
|
+
"workspace plus read-only sandbox controls instead of Grok plan mode.",
|
|
633
|
+
)
|
|
634
|
+
if safe_permission not in ("dontAsk", "default", "auto"):
|
|
635
|
+
raise ConfigError(
|
|
636
|
+
"invalid_grok_config",
|
|
637
|
+
"grok.safePermissionMode must be dontAsk, default, or auto.",
|
|
638
|
+
)
|
|
639
|
+
work_permission = grok.get("workPermissionMode", "auto")
|
|
640
|
+
if work_permission == GROK_BYPASS_PERMISSION_MODE:
|
|
641
|
+
raise ConfigError(
|
|
642
|
+
"invalid_grok_config",
|
|
643
|
+
"grok.workPermissionMode cannot be bypassPermissions; "
|
|
644
|
+
"use policy.harness.grok.work.bypassApprovalsAndSandbox "
|
|
645
|
+
"for explicit Delegate-controlled bypass.",
|
|
646
|
+
)
|
|
647
|
+
if work_permission not in GROK_PERMISSION_MODES:
|
|
648
|
+
raise ConfigError(
|
|
649
|
+
"invalid_grok_config",
|
|
650
|
+
f"grok.workPermissionMode must be one of: {', '.join(GROK_PERMISSION_MODES)}.",
|
|
651
|
+
)
|
|
652
|
+
safe_sandbox = grok.get("safeSandbox", "read-only")
|
|
653
|
+
if safe_sandbox not in GROK_SAFE_SANDBOX_VALUES:
|
|
654
|
+
raise ConfigError(
|
|
655
|
+
"invalid_grok_config",
|
|
656
|
+
f"grok.safeSandbox must be one of: {', '.join(GROK_SAFE_SANDBOX_VALUES)}.",
|
|
657
|
+
)
|
|
658
|
+
work_sandbox = grok.get("workSandbox")
|
|
659
|
+
if work_sandbox is not None and work_sandbox not in GROK_WORK_SANDBOX_VALUES:
|
|
660
|
+
raise ConfigError(
|
|
661
|
+
"invalid_grok_config",
|
|
662
|
+
f"grok.workSandbox must be null or one of: {', '.join(GROK_WORK_SANDBOX_VALUES)}.",
|
|
663
|
+
)
|
|
664
|
+
require_bool(
|
|
665
|
+
grok.get("disableWebSearch", True),
|
|
666
|
+
path="grok.disableWebSearch",
|
|
667
|
+
error="invalid_grok_config",
|
|
668
|
+
)
|
|
669
|
+
require_bool(
|
|
670
|
+
grok.get("noSubagents", False), path="grok.noSubagents", error="invalid_grok_config"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _validate_provider_default_reasoning_effort(
|
|
675
|
+
value: JsonValue,
|
|
676
|
+
*,
|
|
677
|
+
path: str,
|
|
678
|
+
error: str,
|
|
679
|
+
) -> None:
|
|
680
|
+
if value is None:
|
|
681
|
+
return
|
|
682
|
+
if not reasoning.is_valid_effort_string(value):
|
|
683
|
+
raise ConfigError(error, f"{path} must be a non-empty string without whitespace or null.")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _validate_reasoning_effort_value(value: JsonValue, *, path: str) -> str:
|
|
687
|
+
if not reasoning.is_valid_effort_string(value):
|
|
688
|
+
raise ConfigError(
|
|
689
|
+
"invalid_reasoning_config",
|
|
690
|
+
f"{path} must be a non-empty string without whitespace.",
|
|
691
|
+
)
|
|
692
|
+
return value
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _validate_cursor_reasoning_models(value: JsonValue) -> None:
|
|
696
|
+
if value is None:
|
|
697
|
+
return
|
|
698
|
+
if not isinstance(value, dict):
|
|
699
|
+
raise ConfigError(
|
|
700
|
+
"invalid_cursor_config",
|
|
701
|
+
"cursor.reasoningEffortModels must be an object.",
|
|
702
|
+
)
|
|
703
|
+
for effort, model in value.items():
|
|
704
|
+
if not reasoning.is_valid_effort_string(effort):
|
|
705
|
+
raise ConfigError(
|
|
706
|
+
"invalid_cursor_config",
|
|
707
|
+
"cursor.reasoningEffortModels effort keys must be non-empty strings without whitespace.",
|
|
708
|
+
)
|
|
709
|
+
if not isinstance(model, str) or not model:
|
|
710
|
+
raise ConfigError(
|
|
711
|
+
"invalid_cursor_config",
|
|
712
|
+
"cursor.reasoningEffortModels values must be non-empty strings.",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _validate_reasoning_section(section: JsonValue) -> None:
|
|
717
|
+
if section is None:
|
|
718
|
+
return
|
|
719
|
+
if not isinstance(section, dict):
|
|
720
|
+
raise ConfigError("invalid_reasoning_config", "reasoning config must be an object.")
|
|
721
|
+
capabilities = section.get("capabilities")
|
|
722
|
+
if capabilities is None:
|
|
723
|
+
return
|
|
724
|
+
if not isinstance(capabilities, dict):
|
|
725
|
+
raise ConfigError(
|
|
726
|
+
"invalid_reasoning_config",
|
|
727
|
+
"reasoning.capabilities must be an object.",
|
|
728
|
+
)
|
|
729
|
+
for harness, models in capabilities.items():
|
|
730
|
+
# Only codex and droid consult this table at launch. Accepting other
|
|
731
|
+
# keys (e.g. cursor, claude, or a typo) would validate cleanly and then
|
|
732
|
+
# be silently ignored; cursor reasoning lives in cursor.reasoningEffortModels
|
|
733
|
+
# and Claude uses static native --effort labels.
|
|
734
|
+
if harness not in ("codex", "droid"):
|
|
735
|
+
raise ConfigError(
|
|
736
|
+
"invalid_reasoning_config",
|
|
737
|
+
f"reasoning.capabilities.{harness} is not supported; capability "
|
|
738
|
+
"declarations apply to codex and droid only (cursor uses "
|
|
739
|
+
"cursor.reasoningEffortModels; claude uses native --effort labels).",
|
|
740
|
+
)
|
|
741
|
+
if not isinstance(models, dict):
|
|
742
|
+
raise ConfigError(
|
|
743
|
+
"invalid_reasoning_config",
|
|
744
|
+
f"reasoning.capabilities.{harness} must be an object.",
|
|
745
|
+
)
|
|
746
|
+
for model, declaration in models.items():
|
|
747
|
+
if not isinstance(model, str) or not model:
|
|
748
|
+
raise ConfigError(
|
|
749
|
+
"invalid_reasoning_config",
|
|
750
|
+
f"reasoning.capabilities.{harness} model names must be non-empty strings.",
|
|
751
|
+
)
|
|
752
|
+
if not isinstance(declaration, dict):
|
|
753
|
+
raise ConfigError(
|
|
754
|
+
"invalid_reasoning_config",
|
|
755
|
+
f"reasoning.capabilities.{harness}.{model} must be an object.",
|
|
756
|
+
)
|
|
757
|
+
supported = declaration.get("supported")
|
|
758
|
+
if not isinstance(supported, list) or not supported:
|
|
759
|
+
raise ConfigError(
|
|
760
|
+
"invalid_reasoning_config",
|
|
761
|
+
f"reasoning.capabilities.{harness}.{model}.supported must be a non-empty array.",
|
|
762
|
+
)
|
|
763
|
+
supported_values = [
|
|
764
|
+
_validate_reasoning_effort_value(
|
|
765
|
+
item,
|
|
766
|
+
path=f"reasoning.capabilities.{harness}.{model}.supported[]",
|
|
767
|
+
)
|
|
768
|
+
for item in supported
|
|
769
|
+
]
|
|
770
|
+
default = declaration.get("default")
|
|
771
|
+
if default is not None:
|
|
772
|
+
default_value = _validate_reasoning_effort_value(
|
|
773
|
+
default,
|
|
774
|
+
path=f"reasoning.capabilities.{harness}.{model}.default",
|
|
775
|
+
)
|
|
776
|
+
if default_value not in supported_values:
|
|
777
|
+
raise ConfigError(
|
|
778
|
+
"invalid_reasoning_config",
|
|
779
|
+
f"reasoning.capabilities.{harness}.{model}.default must be in supported.",
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class InvalidIsolationError(Exception):
|
|
784
|
+
"""Raised when an isolation value is invalid; caller translates to DelegateError or ConfigError."""
|
|
785
|
+
|
|
786
|
+
def __init__(self, message: str) -> None:
|
|
787
|
+
super().__init__(message)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _normalize_resolved_isolation_boundary(
|
|
791
|
+
value: str,
|
|
792
|
+
*,
|
|
793
|
+
engine: str,
|
|
794
|
+
mode: str,
|
|
795
|
+
) -> str:
|
|
796
|
+
if mode == "safe" and engine in SAFE_ISOLATION_REQUIRED_ENGINES and value == ISOLATION_NONE:
|
|
797
|
+
return ISOLATION_AUTO
|
|
798
|
+
return value
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def resolve_isolation(
|
|
802
|
+
cli_value: str | None = None,
|
|
803
|
+
input_json_value: str | None = None,
|
|
804
|
+
loaded_config: JsonObject | None = None,
|
|
805
|
+
engine: str = "",
|
|
806
|
+
mode: str = "",
|
|
807
|
+
) -> str:
|
|
808
|
+
if cli_value is not None:
|
|
809
|
+
if cli_value not in VALID_ISOLATION_VALUES:
|
|
810
|
+
raise InvalidIsolationError(
|
|
811
|
+
f"--isolation must be one of: {', '.join(VALID_ISOLATION_VALUES)}."
|
|
812
|
+
)
|
|
813
|
+
return _normalize_resolved_isolation_boundary(
|
|
814
|
+
cli_value,
|
|
815
|
+
engine=engine,
|
|
816
|
+
mode=mode,
|
|
817
|
+
)
|
|
818
|
+
if input_json_value is not None:
|
|
819
|
+
if input_json_value not in VALID_ISOLATION_VALUES:
|
|
820
|
+
raise InvalidIsolationError(
|
|
821
|
+
f"isolation must be one of: {', '.join(VALID_ISOLATION_VALUES)}."
|
|
822
|
+
)
|
|
823
|
+
return _normalize_resolved_isolation_boundary(
|
|
824
|
+
input_json_value,
|
|
825
|
+
engine=engine,
|
|
826
|
+
mode=mode,
|
|
827
|
+
)
|
|
828
|
+
if isinstance(loaded_config, dict):
|
|
829
|
+
isolation_cfg = loaded_config.get("isolation")
|
|
830
|
+
if "isolation" in loaded_config and not isinstance(isolation_cfg, dict):
|
|
831
|
+
raise InvalidIsolationError("config isolation must be an object when present.")
|
|
832
|
+
if isinstance(isolation_cfg, dict):
|
|
833
|
+
if mode in isolation_cfg and isolation_cfg[mode] is None:
|
|
834
|
+
raise InvalidIsolationError(
|
|
835
|
+
f"config isolation.{mode} must not be null; "
|
|
836
|
+
f"use one of: {', '.join(VALID_ISOLATION_VALUES)}."
|
|
837
|
+
)
|
|
838
|
+
value = isolation_cfg.get(mode)
|
|
839
|
+
if value is not None:
|
|
840
|
+
if not isinstance(value, str) or value not in VALID_ISOLATION_VALUES:
|
|
841
|
+
raise InvalidIsolationError(
|
|
842
|
+
f"config isolation.{mode} must be one of: {', '.join(VALID_ISOLATION_VALUES)}."
|
|
843
|
+
)
|
|
844
|
+
return _normalize_resolved_isolation_boundary(
|
|
845
|
+
value,
|
|
846
|
+
engine=engine,
|
|
847
|
+
mode=mode,
|
|
848
|
+
)
|
|
849
|
+
# Embedded defaults
|
|
850
|
+
if mode == "work":
|
|
851
|
+
return ISOLATION_NONE
|
|
852
|
+
return ISOLATION_AUTO
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
class ConfigError(Exception):
|
|
856
|
+
def __init__(self, error: str, message: str) -> None:
|
|
857
|
+
super().__init__(message)
|
|
858
|
+
self.error = error
|
|
859
|
+
self.message = message
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def require_non_empty_str(value: JsonValue, *, path: str, error: str) -> None:
|
|
863
|
+
"""Require ``value`` to be a string whose stripped form is non-empty."""
|
|
864
|
+
if not isinstance(value, str) or not value.strip():
|
|
865
|
+
raise ConfigError(error, f"{path} must be a non-empty string.")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def optional_str(value: JsonValue, *, path: str, error: str) -> None:
|
|
869
|
+
"""Allow ``value`` to be a string or null; reject any other type."""
|
|
870
|
+
if value is not None and not isinstance(value, str):
|
|
871
|
+
raise ConfigError(error, f"{path} must be a string or null.")
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def require_bool(value: JsonValue, *, path: str, error: str) -> None:
|
|
875
|
+
"""Require ``value`` to be a boolean."""
|
|
876
|
+
if not isinstance(value, bool):
|
|
877
|
+
raise ConfigError(error, f"{path} must be a boolean.")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def deep_merge(base: JsonObject, override: JsonObject) -> JsonObject:
|
|
881
|
+
merged = copy.deepcopy(base)
|
|
882
|
+
for key, value in override.items():
|
|
883
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
884
|
+
merged[key] = deep_merge(merged[key], value)
|
|
885
|
+
else:
|
|
886
|
+
merged[key] = value
|
|
887
|
+
return merged
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _replace_profile_definitions(merged: JsonObject, override: JsonObject) -> None:
|
|
891
|
+
override_profiles = override.get("profiles")
|
|
892
|
+
if not isinstance(override_profiles, dict):
|
|
893
|
+
return
|
|
894
|
+
override_definitions = override_profiles.get("definitions")
|
|
895
|
+
if not isinstance(override_definitions, dict):
|
|
896
|
+
return
|
|
897
|
+
profiles = merged.get("profiles")
|
|
898
|
+
if not isinstance(profiles, dict):
|
|
899
|
+
return
|
|
900
|
+
definitions = profiles.get("definitions")
|
|
901
|
+
if not isinstance(definitions, dict):
|
|
902
|
+
return
|
|
903
|
+
for name, entry in override_definitions.items():
|
|
904
|
+
definitions[name] = copy.deepcopy(entry)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def merge_config_layer(base: JsonObject, override: JsonObject) -> JsonObject:
|
|
908
|
+
"""Merge one config file or CLI override layer onto ``base``.
|
|
909
|
+
|
|
910
|
+
Profile definitions are replaced atomically per profile name so a higher layer
|
|
911
|
+
cannot inherit stale ``env`` keys from lower layers.
|
|
912
|
+
"""
|
|
913
|
+
merged = deep_merge(base, override)
|
|
914
|
+
_replace_profile_definitions(merged, override)
|
|
915
|
+
return merged
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def default_config_path() -> Path:
|
|
919
|
+
if DEFAULT_CONFIG_PATH is not None:
|
|
920
|
+
return DEFAULT_CONFIG_PATH.expanduser()
|
|
921
|
+
return Path.home() / DEFAULT_CONFIG_RELATIVE
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def config_path() -> Path:
|
|
925
|
+
raw = os.environ.get(CONFIG_ENV, str(default_config_path()))
|
|
926
|
+
return Path(raw).expanduser()
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
PROFILE_CONFIG_NAMES = ("work", "personal")
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def profile_config_path(base_path: Path, profile: str) -> Path:
|
|
933
|
+
"""Path to the AI_PROFILE overlay config for ``profile`` next to ``base_path``.
|
|
934
|
+
|
|
935
|
+
Shared by ``config_commands`` (which writes these overlays) and
|
|
936
|
+
``profile_guard`` (which checks for them), so both agree on
|
|
937
|
+
``~/.delegate/config.<profile>.json`` naming without duplicating the rule.
|
|
938
|
+
"""
|
|
939
|
+
return base_path.with_name(f"{base_path.stem}.{profile}{base_path.suffix}")
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def workspace_config_path(workspace: Path) -> Path:
|
|
943
|
+
return workspace / WORKSPACE_CONFIG_RELATIVE
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def read_config_file(path: Path) -> JsonObject:
|
|
947
|
+
try:
|
|
948
|
+
loaded: JsonValue = json.loads(path.read_text(encoding="utf-8"))
|
|
949
|
+
except json.JSONDecodeError as exc:
|
|
950
|
+
raise ConfigError("invalid_config_json", f"Invalid JSON in {path}: {exc}") from exc
|
|
951
|
+
if not isinstance(loaded, dict):
|
|
952
|
+
raise ConfigError("invalid_config", "Config root must be a JSON object.")
|
|
953
|
+
return loaded
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def load_config(
|
|
957
|
+
path: Path | None = None,
|
|
958
|
+
*,
|
|
959
|
+
workspace: Path | None = None,
|
|
960
|
+
cli_overrides: JsonObject | None = None,
|
|
961
|
+
) -> tuple[JsonObject, str]:
|
|
962
|
+
"""Load config with precedence: cli > DELEGATE_CONFIG > workspace > global > embedded.
|
|
963
|
+
|
|
964
|
+
When DELEGATE_CONFIG is set, the path must exist; a missing file raises ConfigError
|
|
965
|
+
instead of discarding lower-precedence layers.
|
|
966
|
+
"""
|
|
967
|
+
merged = embedded_default_config()
|
|
968
|
+
primary_source = "embedded-default"
|
|
969
|
+
|
|
970
|
+
global_path = default_config_path()
|
|
971
|
+
if global_path.exists():
|
|
972
|
+
merged = merge_config_layer(merged, read_config_file(global_path))
|
|
973
|
+
primary_source = str(global_path)
|
|
974
|
+
|
|
975
|
+
if workspace is not None:
|
|
976
|
+
local_path = workspace_config_path(workspace)
|
|
977
|
+
if local_path.exists():
|
|
978
|
+
merged = merge_config_layer(merged, read_config_file(local_path))
|
|
979
|
+
primary_source = str(local_path)
|
|
980
|
+
|
|
981
|
+
explicit = os.environ.get(CONFIG_ENV)
|
|
982
|
+
if explicit:
|
|
983
|
+
if wsl.should_reject_windows_path(explicit):
|
|
984
|
+
raise ConfigError("windows_path", wsl.windows_path_message(CONFIG_ENV, explicit))
|
|
985
|
+
explicit_path = Path(explicit).expanduser()
|
|
986
|
+
if not explicit_path.exists():
|
|
987
|
+
# Explicit path was requested; do not discard lower-precedence merges.
|
|
988
|
+
raise ConfigError(
|
|
989
|
+
"config_not_found",
|
|
990
|
+
f"{CONFIG_ENV} points to a missing file: {explicit_path}",
|
|
991
|
+
)
|
|
992
|
+
merged = merge_config_layer(merged, read_config_file(explicit_path))
|
|
993
|
+
primary_source = str(explicit_path)
|
|
994
|
+
elif path is not None and path != global_path and path.exists():
|
|
995
|
+
merged = merge_config_layer(merged, read_config_file(path))
|
|
996
|
+
primary_source = str(path)
|
|
997
|
+
|
|
998
|
+
if cli_overrides:
|
|
999
|
+
merged = merge_config_layer(merged, cli_overrides)
|
|
1000
|
+
primary_source = "cli-overrides"
|
|
1001
|
+
|
|
1002
|
+
return merged, primary_source
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def validate_config(config: JsonObject) -> None:
|
|
1006
|
+
cursor = config.get("cursor")
|
|
1007
|
+
droid = config.get("droid")
|
|
1008
|
+
if not isinstance(cursor, dict):
|
|
1009
|
+
raise ConfigError("invalid_cursor_config", "cursor config must be an object.")
|
|
1010
|
+
if "binary" in cursor:
|
|
1011
|
+
raise ConfigError(
|
|
1012
|
+
"invalid_cursor_config",
|
|
1013
|
+
"cursor.binary is not supported; use cursor.argvPrefix as an array of strings.",
|
|
1014
|
+
)
|
|
1015
|
+
prefix = cursor.get("argvPrefix")
|
|
1016
|
+
if (
|
|
1017
|
+
not isinstance(prefix, list)
|
|
1018
|
+
or not prefix
|
|
1019
|
+
or not all(isinstance(x, str) and x for x in prefix)
|
|
1020
|
+
):
|
|
1021
|
+
raise ConfigError(
|
|
1022
|
+
"invalid_cursor_config", "cursor.argvPrefix must be a non-empty array of strings."
|
|
1023
|
+
)
|
|
1024
|
+
require_non_empty_str(
|
|
1025
|
+
cursor.get("defaultModel"), path="cursor.defaultModel", error="invalid_cursor_config"
|
|
1026
|
+
)
|
|
1027
|
+
_validate_provider_default_reasoning_effort(
|
|
1028
|
+
cursor.get("defaultReasoningEffort"),
|
|
1029
|
+
path="cursor.defaultReasoningEffort",
|
|
1030
|
+
error="invalid_cursor_config",
|
|
1031
|
+
)
|
|
1032
|
+
_validate_cursor_reasoning_models(cursor.get("reasoningEffortModels"))
|
|
1033
|
+
if not isinstance(droid, dict):
|
|
1034
|
+
raise ConfigError("invalid_droid_config", "droid config must be an object.")
|
|
1035
|
+
require_non_empty_str(droid.get("binary"), path="droid.binary", error="invalid_droid_config")
|
|
1036
|
+
_validate_provider_default_reasoning_effort(
|
|
1037
|
+
droid.get("defaultReasoningEffort"),
|
|
1038
|
+
path="droid.defaultReasoningEffort",
|
|
1039
|
+
error="invalid_droid_config",
|
|
1040
|
+
)
|
|
1041
|
+
models = droid.get("models")
|
|
1042
|
+
if not isinstance(models, dict):
|
|
1043
|
+
raise ConfigError("invalid_droid_config", "droid.models must be an object.")
|
|
1044
|
+
for alias, model_id in models.items():
|
|
1045
|
+
if not isinstance(alias, str) or not isinstance(model_id, str) or not alias or not model_id:
|
|
1046
|
+
raise ConfigError(
|
|
1047
|
+
"invalid_droid_config", "droid model aliases and ids must be non-empty strings."
|
|
1048
|
+
)
|
|
1049
|
+
tracking = config.get("tracking")
|
|
1050
|
+
if tracking is not None:
|
|
1051
|
+
if not isinstance(tracking, dict):
|
|
1052
|
+
raise ConfigError("invalid_tracking_config", "tracking config must be an object.")
|
|
1053
|
+
completion = tracking.get("completionReport")
|
|
1054
|
+
if completion is not None:
|
|
1055
|
+
if not isinstance(completion, dict):
|
|
1056
|
+
raise ConfigError(
|
|
1057
|
+
"invalid_tracking_config", "tracking.completionReport must be an object."
|
|
1058
|
+
)
|
|
1059
|
+
default_mode = completion.get("defaultMode")
|
|
1060
|
+
if default_mode is not None and default_mode not in COMPLETION_REPORT_MODES:
|
|
1061
|
+
raise ConfigError(
|
|
1062
|
+
"invalid_tracking_config",
|
|
1063
|
+
"tracking.completionReport.defaultMode must be markdown or none.",
|
|
1064
|
+
)
|
|
1065
|
+
retention = tracking.get("retention")
|
|
1066
|
+
if retention is not None:
|
|
1067
|
+
if not isinstance(retention, dict):
|
|
1068
|
+
raise ConfigError(
|
|
1069
|
+
"invalid_tracking_config", "tracking.retention must be an object."
|
|
1070
|
+
)
|
|
1071
|
+
enabled = retention.get("enabled")
|
|
1072
|
+
if enabled is not None and not isinstance(enabled, bool):
|
|
1073
|
+
raise ConfigError(
|
|
1074
|
+
"invalid_tracking_config", "tracking.retention.enabled must be a boolean."
|
|
1075
|
+
)
|
|
1076
|
+
raw_log_days = retention.get("rawLogDays")
|
|
1077
|
+
if raw_log_days is not None:
|
|
1078
|
+
_validate_required_non_negative_int(
|
|
1079
|
+
raw_log_days,
|
|
1080
|
+
path="tracking.retention.rawLogDays",
|
|
1081
|
+
error="invalid_tracking_config",
|
|
1082
|
+
)
|
|
1083
|
+
_validate_policy_section(config.get("policy"))
|
|
1084
|
+
_validate_profiles_section(config.get("profiles"))
|
|
1085
|
+
_validate_codex_section(config.get("codex"))
|
|
1086
|
+
_validate_codex_profile_references(config)
|
|
1087
|
+
_validate_kimi_section(config.get("kimi"))
|
|
1088
|
+
_validate_claude_section(config.get("claude"))
|
|
1089
|
+
_validate_grok_section(config.get("grok"))
|
|
1090
|
+
_validate_reasoning_section(config.get("reasoning"))
|
|
1091
|
+
_validate_isolation_section(config.get("isolation"))
|
|
1092
|
+
_validate_worktrees_section(config.get("worktrees"))
|
|
1093
|
+
_validate_progress_section(config.get("progress"))
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def completion_report_default_mode(config: JsonObject) -> str:
|
|
1097
|
+
tracking = config.get("tracking")
|
|
1098
|
+
if not isinstance(tracking, dict):
|
|
1099
|
+
return COMPLETION_REPORT_MODE_MARKDOWN
|
|
1100
|
+
completion = tracking.get("completionReport")
|
|
1101
|
+
if not isinstance(completion, dict):
|
|
1102
|
+
return COMPLETION_REPORT_MODE_MARKDOWN
|
|
1103
|
+
mode = completion.get("defaultMode", COMPLETION_REPORT_MODE_MARKDOWN)
|
|
1104
|
+
return mode if mode in COMPLETION_REPORT_MODES else COMPLETION_REPORT_MODE_MARKDOWN
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def harness_binary(config: JsonObject, engine: str) -> str:
|
|
1108
|
+
embedded = _EMBEDDED_DEFAULT_CONFIG.get(engine)
|
|
1109
|
+
# 'codex' is an arbitrary safe fallback that only fires when an engine has no
|
|
1110
|
+
# embedded section (an invariant violation), since codex is the baseline harness.
|
|
1111
|
+
default_binary = "codex"
|
|
1112
|
+
if isinstance(embedded, dict) and isinstance(embedded.get("binary"), str):
|
|
1113
|
+
default_binary = embedded["binary"]
|
|
1114
|
+
section = config.get(engine)
|
|
1115
|
+
if isinstance(section, dict) and isinstance(section.get("binary"), str):
|
|
1116
|
+
return section["binary"]
|
|
1117
|
+
return default_binary
|