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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Fail-closed AI_PROFILE guard for the Python CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
``bin/delegate-profile-shim`` is a *template* users may copy in front of the
|
|
4
|
+
installed ``delegate`` command. It is not the only way to reach
|
|
5
|
+
``delegate_agent.cli:main`` — the pip console script, ``python -m
|
|
6
|
+
delegate_agent.cli``, and ``bin/delegate.py`` all call it directly, with no
|
|
7
|
+
shell shim in front. This module re-implements the shim's fail-closed check
|
|
8
|
+
inside the Python CLI itself so the guarantee holds regardless of entrypoint:
|
|
9
|
+
a recognized ``AI_PROFILE`` (``work``/``personal``) with a missing overlay
|
|
10
|
+
config must never silently fall through to the base account for a command
|
|
11
|
+
that launches a child harness or mutates state. See docs/security-model.md.
|
|
12
|
+
|
|
13
|
+
When the shim already validated and exported ``DELEGATE_CONFIG``, this guard
|
|
14
|
+
does not re-fire: its trigger condition requires ``DELEGATE_CONFIG`` to be
|
|
15
|
+
unset, so a successful shim run and a direct Python invocation compose
|
|
16
|
+
without double warnings or a redundant check.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TextIO
|
|
24
|
+
|
|
25
|
+
from delegate_agent import config as delegate_config
|
|
26
|
+
from delegate_agent.errors import DelegateError
|
|
27
|
+
from delegate_agent.request_models import ParsedCommand
|
|
28
|
+
|
|
29
|
+
# Mirrors bin/delegate-profile-shim's `_delegate_is_readonly_request` allow-list.
|
|
30
|
+
# Kept as the real subcommand names the parser produces (not positional argv
|
|
31
|
+
# guessing) so the two layers can only drift if someone edits one and forgets
|
|
32
|
+
# the other, not because of divergent classification logic.
|
|
33
|
+
READ_ONLY_SUBCOMMANDS = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"help",
|
|
36
|
+
"version",
|
|
37
|
+
"agent-help",
|
|
38
|
+
"models",
|
|
39
|
+
"describe",
|
|
40
|
+
"profiles",
|
|
41
|
+
"snapshot",
|
|
42
|
+
"runs",
|
|
43
|
+
"run-output",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
READ_ONLY_WORKTREE_ACTIONS = frozenset({"show", "list"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_read_only_command(parsed: ParsedCommand) -> bool:
|
|
50
|
+
"""Classify a parsed command as read-only diagnostic vs launch/mutation.
|
|
51
|
+
|
|
52
|
+
``capabilities`` is read-only except ``capabilities refresh`` (which can
|
|
53
|
+
spawn a child probe process). ``worktree`` is read-only only for
|
|
54
|
+
``show``/``list``; ``remove``/``prune``/``gc`` mutate the worktree store.
|
|
55
|
+
``config``, ``dry-run``, ``wait``, ``cancel``, and every engine/``run``
|
|
56
|
+
launch remain classified as mutation/launch (conservative by design).
|
|
57
|
+
"""
|
|
58
|
+
subcommand = parsed.subcommand
|
|
59
|
+
if subcommand in READ_ONLY_SUBCOMMANDS:
|
|
60
|
+
return True
|
|
61
|
+
if subcommand == "capabilities":
|
|
62
|
+
return parsed.capabilities is not None and not parsed.capabilities.refresh
|
|
63
|
+
if subcommand == "worktree":
|
|
64
|
+
return parsed.worktree is not None and parsed.worktree.action in READ_ONLY_WORKTREE_ACTIONS
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fix_message(profile: str, config_path: Path) -> str:
|
|
69
|
+
return (
|
|
70
|
+
f"delegate: AI_PROFILE={profile} but {config_path} is missing/unreadable; "
|
|
71
|
+
"refusing to run a launch or mutation command on the wrong account.\n"
|
|
72
|
+
"Fix: create the profile config from config.example.json, or run "
|
|
73
|
+
"'env -u AI_PROFILE delegate config sync-profiles' to materialize missing profile overlays.\n"
|
|
74
|
+
"Temporary bypasses: 'env -u AI_PROFILE delegate ...' uses the base config (work Claude, "
|
|
75
|
+
"ambient Codex), or set 'DELEGATE_CONFIG=/path/to/config.json delegate ...'."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def enforce_profile_guard(parsed: ParsedCommand, *, stderr: TextIO) -> None:
|
|
80
|
+
"""Fail closed when AI_PROFILE names a profile whose overlay config is missing.
|
|
81
|
+
|
|
82
|
+
No-ops (silently) when: DELEGATE_CONFIG is already set (shim precedence,
|
|
83
|
+
or the user set it directly), or AI_PROFILE is unset/empty. Warns and
|
|
84
|
+
proceeds for an unrecognized non-empty AI_PROFILE, or for a read-only
|
|
85
|
+
command with a missing overlay. Raises DelegateError (fail closed) for a
|
|
86
|
+
launch/mutation command with a missing or unreadable overlay.
|
|
87
|
+
"""
|
|
88
|
+
if os.environ.get(delegate_config.CONFIG_ENV):
|
|
89
|
+
return
|
|
90
|
+
profile = os.environ.get("AI_PROFILE", "")
|
|
91
|
+
if not profile:
|
|
92
|
+
return
|
|
93
|
+
if profile not in delegate_config.PROFILE_CONFIG_NAMES:
|
|
94
|
+
print(
|
|
95
|
+
f"delegate: AI_PROFILE={profile!r} is not a recognized profile (work|personal); "
|
|
96
|
+
"running on the base account",
|
|
97
|
+
file=stderr,
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
config_path = delegate_config.profile_config_path(
|
|
102
|
+
delegate_config.default_config_path(), profile
|
|
103
|
+
)
|
|
104
|
+
if config_path.is_file() and os.access(config_path, os.R_OK):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if is_read_only_command(parsed):
|
|
108
|
+
print(
|
|
109
|
+
f"delegate: warning: AI_PROFILE={profile} but {config_path} is missing/unreadable; "
|
|
110
|
+
f"continuing because '{parsed.subcommand}' is read-only. Launch and mutation commands "
|
|
111
|
+
"remain blocked until the profile config exists.",
|
|
112
|
+
file=stderr,
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
raise DelegateError("profile_config_missing", _fix_message(profile, config_path))
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Profile selection, environment injection, and Codex auth helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from delegate_agent import redaction, wsl
|
|
13
|
+
from delegate_agent.errors import DelegateError
|
|
14
|
+
from delegate_agent.git_utils import GIT_QUICK_TIMEOUT_SECONDS
|
|
15
|
+
from delegate_agent.git_utils import run_git as _run_git
|
|
16
|
+
from delegate_agent.harness_events import StreamAccumulator
|
|
17
|
+
from delegate_agent.json_types import JsonObject
|
|
18
|
+
|
|
19
|
+
STDERR_TAIL_LIMIT = 8_000
|
|
20
|
+
|
|
21
|
+
_RATE_LIMIT_PATTERN = re.compile(r"rate limit", re.IGNORECASE)
|
|
22
|
+
|
|
23
|
+
_USAGE_LIMIT_PATTERNS = (
|
|
24
|
+
re.compile(r"usage limit", re.IGNORECASE),
|
|
25
|
+
re.compile(r"insufficient_quota", re.IGNORECASE),
|
|
26
|
+
re.compile(r"exceeded your current quota", re.IGNORECASE),
|
|
27
|
+
_RATE_LIMIT_PATTERN,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_ACCOUNT_QUOTA_CONTEXT = re.compile(
|
|
31
|
+
r"(quota|usage|billing|subscription|account|credit)",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProfileResolution:
|
|
38
|
+
name: str | None
|
|
39
|
+
source: str | None
|
|
40
|
+
warnings: tuple[str, ...] = ()
|
|
41
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
codex_home: str | None = None
|
|
43
|
+
codex_fallback_home: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def empty_profile_resolution() -> ProfileResolution:
|
|
47
|
+
return ProfileResolution(name=None, source=None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def profiles_section(config: JsonObject) -> JsonObject:
|
|
51
|
+
section = config.get("profiles")
|
|
52
|
+
return section if isinstance(section, dict) else {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def profile_definitions(config: JsonObject) -> dict[str, JsonObject]:
|
|
56
|
+
definitions = profiles_section(config).get("definitions")
|
|
57
|
+
if not isinstance(definitions, dict):
|
|
58
|
+
return {}
|
|
59
|
+
return {
|
|
60
|
+
name.strip(): entry
|
|
61
|
+
for name, entry in definitions.items()
|
|
62
|
+
if isinstance(name, str) and name.strip() and isinstance(entry, dict)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _expanded_env_value(value: str) -> str:
|
|
67
|
+
return os.path.expanduser(os.path.expandvars(value.strip()))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unknown_profile_error(name: str, definitions: Mapping[str, JsonObject]) -> DelegateError:
|
|
71
|
+
defined = sorted(definitions)
|
|
72
|
+
if defined:
|
|
73
|
+
hint = f"Defined profiles: {', '.join(defined)}."
|
|
74
|
+
else:
|
|
75
|
+
hint = "No profiles are defined; add a profiles.definitions entry to config."
|
|
76
|
+
return DelegateError(
|
|
77
|
+
"unknown_profile",
|
|
78
|
+
f"Unknown auth profile: {name}. {hint} Run delegate profiles to inspect the active profile.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def profile_env(config: JsonObject, name: str) -> dict[str, str]:
|
|
83
|
+
definitions = profile_definitions(config)
|
|
84
|
+
profile = definitions.get(name)
|
|
85
|
+
if profile is None:
|
|
86
|
+
raise _unknown_profile_error(name, definitions)
|
|
87
|
+
env = profile.get("env")
|
|
88
|
+
if not isinstance(env, dict):
|
|
89
|
+
return {}
|
|
90
|
+
return {
|
|
91
|
+
key: _expanded_env_value(value)
|
|
92
|
+
for key, value in env.items()
|
|
93
|
+
if isinstance(key, str) and isinstance(value, str)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def codex_section(config: JsonObject) -> JsonObject:
|
|
98
|
+
section = config.get("codex")
|
|
99
|
+
return section if isinstance(section, dict) else {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def codex_fallback_profile(config: JsonObject) -> str | None:
|
|
103
|
+
fallback = codex_section(config).get("fallbackProfile")
|
|
104
|
+
if isinstance(fallback, str) and fallback.strip():
|
|
105
|
+
return fallback.strip()
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _warn_once(warnings: list[str], warning: str) -> None:
|
|
110
|
+
if warning not in warnings:
|
|
111
|
+
warnings.append(warning)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_default_profile(
|
|
115
|
+
config: JsonObject, definitions: Mapping[str, JsonObject]
|
|
116
|
+
) -> str | None:
|
|
117
|
+
default = profiles_section(config).get("default")
|
|
118
|
+
if isinstance(default, str) and default in definitions:
|
|
119
|
+
return default
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_active_profile(
|
|
124
|
+
config: JsonObject,
|
|
125
|
+
env: Mapping[str, str | None],
|
|
126
|
+
cli_override: str | None = None,
|
|
127
|
+
) -> ProfileResolution:
|
|
128
|
+
definitions = profile_definitions(config)
|
|
129
|
+
warnings: list[str] = []
|
|
130
|
+
selected: str | None = None
|
|
131
|
+
source: str | None = None
|
|
132
|
+
|
|
133
|
+
if cli_override is not None:
|
|
134
|
+
selected = cli_override.strip()
|
|
135
|
+
if not selected or selected not in definitions:
|
|
136
|
+
raise _unknown_profile_error(cli_override, definitions)
|
|
137
|
+
source = "flag"
|
|
138
|
+
elif not definitions:
|
|
139
|
+
return empty_profile_resolution()
|
|
140
|
+
else:
|
|
141
|
+
detect_from = profiles_section(config).get("detectFrom")
|
|
142
|
+
for var_name in detect_from if isinstance(detect_from, list) else []:
|
|
143
|
+
if not isinstance(var_name, str):
|
|
144
|
+
continue
|
|
145
|
+
raw = env.get(var_name)
|
|
146
|
+
value = raw.strip() if isinstance(raw, str) else ""
|
|
147
|
+
if not value:
|
|
148
|
+
continue
|
|
149
|
+
if value not in definitions:
|
|
150
|
+
safe_value = redaction.redact_mapping_value(var_name, value)
|
|
151
|
+
_warn_once(
|
|
152
|
+
warnings,
|
|
153
|
+
f"profile detect var {var_name}={safe_value} is not defined; ignoring it.",
|
|
154
|
+
)
|
|
155
|
+
continue
|
|
156
|
+
if selected is None:
|
|
157
|
+
selected = value
|
|
158
|
+
source = var_name
|
|
159
|
+
continue
|
|
160
|
+
if value != selected:
|
|
161
|
+
_warn_once(
|
|
162
|
+
warnings,
|
|
163
|
+
f"profile mismatch ({source}={selected} vs {var_name}={value}); using {selected}.",
|
|
164
|
+
)
|
|
165
|
+
if selected is None:
|
|
166
|
+
selected = _resolve_default_profile(config, definitions)
|
|
167
|
+
if selected is not None:
|
|
168
|
+
source = "default"
|
|
169
|
+
|
|
170
|
+
active_env = profile_env(config, selected) if selected is not None else {}
|
|
171
|
+
fallback_profile = codex_fallback_profile(config)
|
|
172
|
+
fallback_home = None
|
|
173
|
+
if fallback_profile is not None:
|
|
174
|
+
fallback_home = profile_env(config, fallback_profile).get("CODEX_HOME")
|
|
175
|
+
return ProfileResolution(
|
|
176
|
+
name=selected,
|
|
177
|
+
source=source,
|
|
178
|
+
warnings=tuple(warnings),
|
|
179
|
+
env=active_env,
|
|
180
|
+
codex_home=active_env.get("CODEX_HOME"),
|
|
181
|
+
codex_fallback_home=fallback_home,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def child_environment(
|
|
186
|
+
base: dict[str, str] | None = None, overrides: dict[str, str] | None = None
|
|
187
|
+
) -> dict[str, str]:
|
|
188
|
+
env = dict(os.environ)
|
|
189
|
+
if base:
|
|
190
|
+
env.update(base)
|
|
191
|
+
if overrides:
|
|
192
|
+
env.update(overrides)
|
|
193
|
+
return env
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _auth_file_readable(path: Path) -> bool:
|
|
197
|
+
try:
|
|
198
|
+
return path.is_file() and os.access(path, os.R_OK)
|
|
199
|
+
except OSError:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def preflight_codex_home(codex_home: str, *, codex: JsonObject) -> None:
|
|
204
|
+
if wsl.should_reject_windows_path(codex_home):
|
|
205
|
+
raise DelegateError("windows_path", wsl.windows_path_message("CODEX_HOME", codex_home))
|
|
206
|
+
home = Path(_expanded_env_value(codex_home))
|
|
207
|
+
if not home.is_absolute():
|
|
208
|
+
raise DelegateError(
|
|
209
|
+
"codex_home_not_absolute",
|
|
210
|
+
f"Codex auth preflight failed: CODEX_HOME must be absolute after expansion, "
|
|
211
|
+
f"got {codex_home!r} -> {home}. A relative path resolves against different cwds "
|
|
212
|
+
"for preflight vs the spawned child and can bill the wrong account.",
|
|
213
|
+
)
|
|
214
|
+
auth_json = home / "auth.json"
|
|
215
|
+
if not _auth_file_readable(auth_json):
|
|
216
|
+
raise DelegateError(
|
|
217
|
+
"codex_auth_unavailable",
|
|
218
|
+
f"Codex auth preflight failed: unreadable auth.json at {auth_json}.",
|
|
219
|
+
)
|
|
220
|
+
profile_overlay = codex.get("profile")
|
|
221
|
+
if isinstance(profile_overlay, str) and profile_overlay.strip():
|
|
222
|
+
config_toml = home / "config.toml"
|
|
223
|
+
if not _auth_file_readable(config_toml):
|
|
224
|
+
raise DelegateError(
|
|
225
|
+
"codex_auth_unavailable",
|
|
226
|
+
f"Codex auth preflight failed: unreadable config.toml at {config_toml}.",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _request_resolution(request: object) -> ProfileResolution:
|
|
231
|
+
resolution = getattr(request, "profile_resolution", None)
|
|
232
|
+
return resolution if resolution is not None else empty_profile_resolution()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def preflight_codex_request(request: object, codex_config: JsonObject) -> None:
|
|
236
|
+
resolution = _request_resolution(request)
|
|
237
|
+
if resolution.name is not None and resolution.codex_home is None:
|
|
238
|
+
raise DelegateError(
|
|
239
|
+
"profile_missing_codex_home",
|
|
240
|
+
f"Profile {resolution.name!r} is active for a Codex Run but does not define CODEX_HOME. "
|
|
241
|
+
f"Add it under profiles.definitions.{resolution.name}.env.CODEX_HOME.",
|
|
242
|
+
)
|
|
243
|
+
if resolution.codex_home is None:
|
|
244
|
+
return
|
|
245
|
+
preflight_codex_home(resolution.codex_home, codex=codex_config)
|
|
246
|
+
fallback_home = resolution.codex_fallback_home
|
|
247
|
+
if fallback_home is not None and not codex_homes_same_account(
|
|
248
|
+
resolution.codex_home, fallback_home
|
|
249
|
+
):
|
|
250
|
+
preflight_codex_home(fallback_home, codex=codex_config)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _codex_auth_file(home: str) -> Path:
|
|
254
|
+
return Path(_expanded_env_value(home), "auth.json")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def codex_homes_same_account(primary_home: str, fallback_home: str) -> bool:
|
|
258
|
+
primary_auth = _codex_auth_file(primary_home)
|
|
259
|
+
fallback_auth = _codex_auth_file(fallback_home)
|
|
260
|
+
try:
|
|
261
|
+
return primary_auth.resolve() == fallback_auth.resolve()
|
|
262
|
+
except (OSError, RuntimeError):
|
|
263
|
+
# Symlink loop or unresolvable path: compare without re-resolving (which would
|
|
264
|
+
# raise again) — fall back to a normalized non-resolving path comparison.
|
|
265
|
+
return os.path.normpath(primary_auth) == os.path.normpath(fallback_auth)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def codex_fallback_env_overrides(resolution: ProfileResolution) -> dict[str, str] | None:
|
|
269
|
+
if resolution.codex_home is None or resolution.codex_fallback_home is None:
|
|
270
|
+
return None
|
|
271
|
+
if codex_homes_same_account(resolution.codex_home, resolution.codex_fallback_home):
|
|
272
|
+
return None
|
|
273
|
+
return {**resolution.env, "CODEX_HOME": resolution.codex_fallback_home}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def classify_codex_usage_limit(stderr_text: str) -> bool:
|
|
277
|
+
if not stderr_text.strip():
|
|
278
|
+
return False
|
|
279
|
+
for pattern in _USAGE_LIMIT_PATTERNS:
|
|
280
|
+
if not pattern.search(stderr_text):
|
|
281
|
+
continue
|
|
282
|
+
if pattern is _RATE_LIMIT_PATTERN and not _ACCOUNT_QUOTA_CONTEXT.search(stderr_text):
|
|
283
|
+
continue
|
|
284
|
+
return True
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def read_bounded_stderr_tail(stderr_log: Path, *, limit: int = STDERR_TAIL_LIMIT) -> str:
|
|
289
|
+
if not stderr_log.exists():
|
|
290
|
+
return ""
|
|
291
|
+
if limit <= 0:
|
|
292
|
+
return ""
|
|
293
|
+
with stderr_log.open("rb") as handle:
|
|
294
|
+
handle.seek(0, os.SEEK_END)
|
|
295
|
+
handle.seek(max(handle.tell() - limit, 0), os.SEEK_SET)
|
|
296
|
+
text = handle.read(limit).decode("utf-8", errors="replace")
|
|
297
|
+
return redaction.redact_string(text)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def accumulator_had_tool_events(accumulator: StreamAccumulator) -> bool:
|
|
301
|
+
return any(event.kind in {"tool.started", "tool.completed"} for event in accumulator.events)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def capture_workspace_porcelain(cwd: str) -> str | None:
|
|
305
|
+
try:
|
|
306
|
+
result = _run_git(
|
|
307
|
+
cwd,
|
|
308
|
+
["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=none"],
|
|
309
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
310
|
+
)
|
|
311
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
312
|
+
return None
|
|
313
|
+
if result.returncode != 0:
|
|
314
|
+
return None
|
|
315
|
+
return result.stdout
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def capture_head_oid(cwd: str) -> str | None:
|
|
319
|
+
try:
|
|
320
|
+
result = _run_git(
|
|
321
|
+
cwd,
|
|
322
|
+
["rev-parse", "--verify", "HEAD"],
|
|
323
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
324
|
+
)
|
|
325
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
326
|
+
return None
|
|
327
|
+
if result.returncode != 0:
|
|
328
|
+
return None
|
|
329
|
+
head = result.stdout.strip()
|
|
330
|
+
return head or None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class WorkspaceBaseline:
|
|
335
|
+
porcelain: str
|
|
336
|
+
head: str
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def capture_workspace_baseline(cwd: str) -> WorkspaceBaseline | None:
|
|
340
|
+
porcelain = capture_workspace_porcelain(cwd)
|
|
341
|
+
if porcelain is None:
|
|
342
|
+
return None
|
|
343
|
+
head = capture_head_oid(cwd)
|
|
344
|
+
if head is None:
|
|
345
|
+
return None
|
|
346
|
+
return WorkspaceBaseline(porcelain=porcelain, head=head)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def workspace_is_clean(porcelain: str | None) -> bool:
|
|
350
|
+
if porcelain is None:
|
|
351
|
+
return False
|
|
352
|
+
return porcelain.strip() == ""
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def workspace_baseline_unchanged(cwd: str, baseline: WorkspaceBaseline | None) -> bool:
|
|
356
|
+
if baseline is None:
|
|
357
|
+
return False
|
|
358
|
+
current = capture_workspace_baseline(cwd)
|
|
359
|
+
if current is None:
|
|
360
|
+
return False
|
|
361
|
+
return current.porcelain == baseline.porcelain and current.head == baseline.head
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def work_mode_safe_for_codex_fallback(cwd: str, baseline: WorkspaceBaseline | None) -> bool:
|
|
365
|
+
if baseline is None:
|
|
366
|
+
return False
|
|
367
|
+
if not workspace_is_clean(baseline.porcelain):
|
|
368
|
+
return False
|
|
369
|
+
return workspace_baseline_unchanged(cwd, baseline)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def codex_auth_fallback_metadata(
|
|
373
|
+
*,
|
|
374
|
+
reason: str,
|
|
375
|
+
primary_auth_profile: str | None,
|
|
376
|
+
fallback_auth_profile: str | None,
|
|
377
|
+
primary_exit_code: int,
|
|
378
|
+
fallback_exit_code: int,
|
|
379
|
+
primary_stderr_tail: str,
|
|
380
|
+
) -> JsonObject:
|
|
381
|
+
return {
|
|
382
|
+
"triggered": True,
|
|
383
|
+
"reason": reason,
|
|
384
|
+
"primaryAuthProfile": primary_auth_profile,
|
|
385
|
+
"fallbackProfile": fallback_auth_profile,
|
|
386
|
+
"primaryExitCode": primary_exit_code,
|
|
387
|
+
"fallbackExitCode": fallback_exit_code,
|
|
388
|
+
"primaryStderrTail": primary_stderr_tail,
|
|
389
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SKILL_REVIEW_PREFIX = """## Delegate sub-agent skill review requirement
|
|
4
|
+
|
|
5
|
+
Before doing the task, review the full list of skills available in your current agent environment. Load/read and apply any skill instructions that are relevant to the task, workspace, tools, code quality, verification, or final deliverable. If no skill is relevant, proceed normally after explicitly deciding that. This requirement is mandatory for every Delegate Agent run; do not skip it just because the parent prompt did not mention skills.
|
|
6
|
+
|
|
7
|
+
Respect the current Delegate run mode. In safe/read-only mode, skill instructions may guide analysis, review, or recommendations, but must not override the read-only requirement.
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
COMPLETION_REPORT_SUFFIX = """
|
|
12
|
+
|
|
13
|
+
## Delegate completion report requirement
|
|
14
|
+
|
|
15
|
+
When you finish, include a concise completion report for the parent agent before
|
|
16
|
+
any operator-requested final payload:
|
|
17
|
+
|
|
18
|
+
- Status: completed / blocked / failed
|
|
19
|
+
- What you did or found
|
|
20
|
+
- Files changed or reviewed
|
|
21
|
+
- Verification run and result
|
|
22
|
+
- Remaining risks or follow-ups
|
|
23
|
+
|
|
24
|
+
Keep it concise. Do not include raw logs unless explicitly relevant. If the
|
|
25
|
+
operator requested an exact final payload such as bare JSON, put that payload
|
|
26
|
+
last after the report, without wrapping it in the report.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prepend_skill_review_instructions(prompt: str) -> str:
|
|
31
|
+
if prompt.startswith(SKILL_REVIEW_PREFIX):
|
|
32
|
+
return prompt
|
|
33
|
+
return SKILL_REVIEW_PREFIX + prompt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def append_completion_report_instructions(prompt: str) -> str:
|
|
37
|
+
if prompt.rstrip().endswith(COMPLETION_REPORT_SUFFIX.strip()):
|
|
38
|
+
return prompt
|
|
39
|
+
return prompt + COMPLETION_REPORT_SUFFIX
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
CURSOR_PROMPT_REDACTION = "<prompt redacted: cursor argv transport>"
|
|
4
|
+
KIMI_PROMPT_REDACTION = "<prompt redacted: kimi argv transport>"
|
|
5
|
+
PROMPT_FILE_ARG_PLACEHOLDER = "<delegate-prompt-file>"
|
|
6
|
+
PROMPT_FILE_DISPLAY = "<prompt file>"
|
|
7
|
+
DROID_PROMPT_FILE_ARG_PLACEHOLDER = PROMPT_FILE_ARG_PLACEHOLDER
|
|
8
|
+
DROID_PROMPT_FILE_DISPLAY = PROMPT_FILE_DISPLAY
|
|
9
|
+
|
|
10
|
+
PROMPT_TRANSPORT_ARGV = "argv"
|
|
11
|
+
PROMPT_TRANSPORT_FILE = "file"
|
|
12
|
+
PROMPT_TRANSPORT_STDIN = "stdin"
|