cc-plugin-codex 0.1.4__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.
- cc_plugin_codex/__init__.py +5 -0
- cc_plugin_codex/claude.py +284 -0
- cc_plugin_codex/cli_contract.py +122 -0
- cc_plugin_codex/config.py +172 -0
- cc_plugin_codex/context.py +210 -0
- cc_plugin_codex/jobs.py +561 -0
- cc_plugin_codex/normalize.py +243 -0
- cc_plugin_codex/preflight.py +94 -0
- cc_plugin_codex/py.typed +0 -0
- cc_plugin_codex/schemas.py +344 -0
- cc_plugin_codex/server.py +1656 -0
- cc_plugin_codex-0.1.4.dist-info/METADATA +223 -0
- cc_plugin_codex-0.1.4.dist-info/RECORD +16 -0
- cc_plugin_codex-0.1.4.dist-info/WHEEL +4 -0
- cc_plugin_codex-0.1.4.dist-info/entry_points.txt +2 -0
- cc_plugin_codex-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Build and run the `claude` CLI invocation; classify failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
from anyio.to_thread import run_sync
|
|
16
|
+
|
|
17
|
+
from cc_plugin_codex import cli_contract, preflight
|
|
18
|
+
from cc_plugin_codex.config import (
|
|
19
|
+
INDEPENDENT_CRITIC_PROMPT,
|
|
20
|
+
access_flags,
|
|
21
|
+
config_mode_flags,
|
|
22
|
+
)
|
|
23
|
+
from cc_plugin_codex.schemas import ErrorInfo
|
|
24
|
+
|
|
25
|
+
_BUDGET_REPAIR = (
|
|
26
|
+
"Raise max_budget_usd or reduce context. For small prompts, try at least "
|
|
27
|
+
"$0.10-$0.20; lower best-effort budgets can spend and still stop before a "
|
|
28
|
+
"useful answer."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from cc_plugin_codex.preflight import FlagSupport
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ClaudeRun:
|
|
37
|
+
stdout: str
|
|
38
|
+
stderr: str
|
|
39
|
+
exit_code: int
|
|
40
|
+
elapsed_ms: int
|
|
41
|
+
timed_out: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _gate_optional(tokens: list[str], fs: FlagSupport) -> tuple[list[str], list[str]]:
|
|
45
|
+
"""Drop any HELP_GATED flag (and its value, if it takes one) the installed
|
|
46
|
+
`claude` does not advertise in --help. Returns (kept_tokens, dropped_flags).
|
|
47
|
+
ALWAYS_SEND flags are never in HELP_GATED_FLAGS, so they always survive."""
|
|
48
|
+
kept: list[str] = []
|
|
49
|
+
dropped: list[str] = []
|
|
50
|
+
i = 0
|
|
51
|
+
while i < len(tokens):
|
|
52
|
+
token = tokens[i]
|
|
53
|
+
takes_value = cli_contract.HELP_GATED_FLAGS.get(token)
|
|
54
|
+
if takes_value is not None and not preflight.is_supported(token, fs):
|
|
55
|
+
dropped.append(token)
|
|
56
|
+
i += 2 if takes_value else 1
|
|
57
|
+
continue
|
|
58
|
+
kept.append(token)
|
|
59
|
+
i += 1
|
|
60
|
+
return kept, dropped
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_command(
|
|
64
|
+
prompt: str,
|
|
65
|
+
config_mode: str,
|
|
66
|
+
access: str,
|
|
67
|
+
model: str | None,
|
|
68
|
+
max_budget_usd: float,
|
|
69
|
+
effort: str | None = None,
|
|
70
|
+
flag_support: FlagSupport | None = None,
|
|
71
|
+
) -> tuple[list[str], list[str]]:
|
|
72
|
+
"""Build the `claude` invocation. Returns (cmd, dropped_optional_flags).
|
|
73
|
+
|
|
74
|
+
Guarantee-bearing flags are sent unconditionally; HELP_GATED (depth/cosmetic)
|
|
75
|
+
flags are dropped when the installed CLI does not list them, so a minor
|
|
76
|
+
upstream change degrades instead of aborting a paid run. dropped_optional_flags
|
|
77
|
+
feeds Meta.compat_warnings."""
|
|
78
|
+
fs = flag_support if flag_support is not None else preflight.flag_support()
|
|
79
|
+
# --no-chrome disables the "Claude in Chrome" integration, which could
|
|
80
|
+
# otherwise open an interactive picker that hangs an unattended run until the
|
|
81
|
+
# timeout (burning the whole timeout and the spend) instead of answering.
|
|
82
|
+
tokens = [cli_contract.CLAUDE_BIN, *cli_contract.CORE_INVOCATION, "--no-chrome"]
|
|
83
|
+
tokens += config_mode_flags(config_mode)
|
|
84
|
+
tokens += access_flags(access)
|
|
85
|
+
tokens += ["--append-system-prompt", INDEPENDENT_CRITIC_PROMPT]
|
|
86
|
+
tokens += ["--max-budget-usd", f"{max_budget_usd}"]
|
|
87
|
+
if effort and effort in cli_contract.VALID_EFFORTS:
|
|
88
|
+
tokens += ["--effort", effort]
|
|
89
|
+
if model:
|
|
90
|
+
tokens += ["--model", model]
|
|
91
|
+
cmd, dropped = _gate_optional(tokens, fs)
|
|
92
|
+
# Gate BEFORE appending the prompt so a prompt that contains "--effort" etc.
|
|
93
|
+
# can never be mistaken for a flag.
|
|
94
|
+
cmd += [cli_contract.END_OF_OPTIONS, prompt]
|
|
95
|
+
return cmd, dropped
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def auth_status(timeout_seconds: int = 10) -> tuple[bool | None, str | None]:
|
|
99
|
+
"""Probe `claude auth status` without making a paid call.
|
|
100
|
+
|
|
101
|
+
Returns (logged_in, detail). logged_in is None when the probe could not run
|
|
102
|
+
(claude missing, timeout) so callers can report 'unknown' rather than a
|
|
103
|
+
misleading False. detail is a NON-identifying phrase, never the raw CLI output:
|
|
104
|
+
`claude auth status` prints the account email and organization, which would leak
|
|
105
|
+
into shared logs/transcripts. The boolean already carries the machine-readable
|
|
106
|
+
truth, so we deliberately drop the raw text."""
|
|
107
|
+
try:
|
|
108
|
+
proc = subprocess.run(
|
|
109
|
+
[cli_contract.CLAUDE_BIN, *cli_contract.AUTH_STATUS_ARGS],
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
timeout=timeout_seconds,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
except (OSError, subprocess.SubprocessError):
|
|
116
|
+
return None, None
|
|
117
|
+
logged_in = proc.returncode == 0
|
|
118
|
+
detail = (
|
|
119
|
+
"Claude CLI reports an authenticated session."
|
|
120
|
+
if logged_in
|
|
121
|
+
else "Claude CLI reports no authenticated session; run `claude /login`."
|
|
122
|
+
)
|
|
123
|
+
return logged_in, detail
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _kill_process_tree(proc: subprocess.Popen) -> None:
|
|
127
|
+
"""Best-effort terminate the process and its children. POSIX: kill the
|
|
128
|
+
process group (the child is its own session leader). Falls back to killing
|
|
129
|
+
just the process where process groups are unavailable (e.g. Windows)."""
|
|
130
|
+
if proc.poll() is not None:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
if hasattr(os, "killpg"):
|
|
134
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
135
|
+
else: # pragma: no cover - non-POSIX fallback
|
|
136
|
+
proc.kill()
|
|
137
|
+
except (ProcessLookupError, PermissionError):
|
|
138
|
+
with contextlib.suppress(ProcessLookupError):
|
|
139
|
+
proc.kill()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def run_claude_async(cmd: list[str], cwd: str, timeout_seconds: int) -> ClaudeRun:
|
|
143
|
+
"""Run `claude` as a subprocess, returning a ClaudeRun.
|
|
144
|
+
|
|
145
|
+
The subprocess is started in its own session (process group) so that, on a
|
|
146
|
+
timeout OR an MCP request cancellation, we can terminate the whole tree
|
|
147
|
+
rather than orphaning a paid Claude run."""
|
|
148
|
+
start = time.monotonic()
|
|
149
|
+
try:
|
|
150
|
+
proc = subprocess.Popen(
|
|
151
|
+
cmd,
|
|
152
|
+
cwd=cwd,
|
|
153
|
+
stdout=subprocess.PIPE,
|
|
154
|
+
stderr=subprocess.PIPE,
|
|
155
|
+
text=True,
|
|
156
|
+
start_new_session=True,
|
|
157
|
+
)
|
|
158
|
+
except OSError:
|
|
159
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
160
|
+
return ClaudeRun("", "claude_not_found", 127, elapsed, False)
|
|
161
|
+
|
|
162
|
+
def _wait() -> tuple[str, str, bool]:
|
|
163
|
+
try:
|
|
164
|
+
out, err = proc.communicate(timeout=timeout_seconds)
|
|
165
|
+
return out, err, False
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
_kill_process_tree(proc)
|
|
168
|
+
out, err = proc.communicate()
|
|
169
|
+
return out, err, True
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
out, err, timed_out = await run_sync(_wait, abandon_on_cancel=True)
|
|
173
|
+
except anyio.get_cancelled_exc_class():
|
|
174
|
+
_kill_process_tree(proc)
|
|
175
|
+
raise
|
|
176
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
177
|
+
if timed_out:
|
|
178
|
+
return ClaudeRun("", "timeout", -9, elapsed, True)
|
|
179
|
+
return ClaudeRun(out, err, proc.returncode, elapsed, False)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def classify_failure(run: ClaudeRun) -> ErrorInfo:
|
|
183
|
+
env = None
|
|
184
|
+
with contextlib.suppress(json.JSONDecodeError, ValueError, TypeError):
|
|
185
|
+
env = json.loads(run.stdout)
|
|
186
|
+
if run.stderr == "claude_not_found":
|
|
187
|
+
return ErrorInfo(
|
|
188
|
+
code="claude_not_found",
|
|
189
|
+
message="The `claude` CLI was not found on PATH.",
|
|
190
|
+
repair="Install Claude Code and ensure `claude` is on PATH.",
|
|
191
|
+
)
|
|
192
|
+
if run.timed_out:
|
|
193
|
+
return ErrorInfo(
|
|
194
|
+
code="timeout",
|
|
195
|
+
message="claude exceeded the timeout.",
|
|
196
|
+
repair="Narrow the scope/focus or raise timeout_seconds.",
|
|
197
|
+
retryable=True,
|
|
198
|
+
)
|
|
199
|
+
if isinstance(env, dict) and env.get("is_error"):
|
|
200
|
+
subtype = str(env.get("subtype") or "").lower()
|
|
201
|
+
result = str(env.get("result") or "")
|
|
202
|
+
structured_blob = f"{subtype}\n{result}".lower()
|
|
203
|
+
if "api_key" in structured_blob or "invalid api key" in structured_blob:
|
|
204
|
+
return ErrorInfo(
|
|
205
|
+
code="api_key_invalid",
|
|
206
|
+
message="ANTHROPIC_API_KEY is invalid.",
|
|
207
|
+
repair="Set a valid ANTHROPIC_API_KEY, or use config_mode "
|
|
208
|
+
"inherit/scoped to use your existing login.",
|
|
209
|
+
)
|
|
210
|
+
if "auth" in structured_blob or "login" in structured_blob:
|
|
211
|
+
return ErrorInfo(
|
|
212
|
+
code="claude_auth_required",
|
|
213
|
+
message="claude is not authenticated.",
|
|
214
|
+
repair="Run `claude /login`.",
|
|
215
|
+
)
|
|
216
|
+
if "budget" in structured_blob:
|
|
217
|
+
return ErrorInfo(
|
|
218
|
+
code="budget_exceeded",
|
|
219
|
+
message="claude reached the max-budget stop threshold "
|
|
220
|
+
"(a best-effort limit, not a hard cap).",
|
|
221
|
+
repair=_BUDGET_REPAIR,
|
|
222
|
+
retryable=True,
|
|
223
|
+
)
|
|
224
|
+
if "permission" in structured_blob or "denied" in structured_blob:
|
|
225
|
+
return ErrorInfo(
|
|
226
|
+
code="claude_permission_error",
|
|
227
|
+
message="claude was denied a requested permission.",
|
|
228
|
+
repair="Use access=toolless, or allow the needed read-only tools.",
|
|
229
|
+
)
|
|
230
|
+
if "rate" in structured_blob or "overloaded" in structured_blob:
|
|
231
|
+
return ErrorInfo(
|
|
232
|
+
code="nonzero_exit",
|
|
233
|
+
message=f"claude reported a retryable error: {result[:200]}",
|
|
234
|
+
repair="Retry later, or reduce request size.",
|
|
235
|
+
retryable=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
extra = ""
|
|
239
|
+
if isinstance(env, dict):
|
|
240
|
+
extra = f"{env.get('subtype', '')} {env.get('result', '')}"
|
|
241
|
+
blob = f"{extra}\n{run.stdout}\n{run.stderr}".lower()
|
|
242
|
+
if "invalid api key" in blob:
|
|
243
|
+
return ErrorInfo(
|
|
244
|
+
code="api_key_invalid",
|
|
245
|
+
message="ANTHROPIC_API_KEY is invalid.",
|
|
246
|
+
repair="Set a valid ANTHROPIC_API_KEY, or use config_mode "
|
|
247
|
+
"inherit/scoped to use your existing login.",
|
|
248
|
+
)
|
|
249
|
+
if "not logged in" in blob or "/login" in blob:
|
|
250
|
+
return ErrorInfo(
|
|
251
|
+
code="claude_auth_required",
|
|
252
|
+
message="claude is not authenticated.",
|
|
253
|
+
repair="Run `claude /login`.",
|
|
254
|
+
)
|
|
255
|
+
if "budget" in blob:
|
|
256
|
+
return ErrorInfo(
|
|
257
|
+
code="budget_exceeded",
|
|
258
|
+
message="claude reached the max-budget stop threshold "
|
|
259
|
+
"(a best-effort limit, not a hard cap).",
|
|
260
|
+
repair=_BUDGET_REPAIR,
|
|
261
|
+
retryable=True,
|
|
262
|
+
)
|
|
263
|
+
# An unknown flag / invalid value means the CLI contract drifted from what this
|
|
264
|
+
# plugin sends. Check last so an auth/budget message is never misread as drift.
|
|
265
|
+
if cli_contract.is_contract_drift(run.stderr, run.stdout):
|
|
266
|
+
return contract_changed_error()
|
|
267
|
+
return ErrorInfo(
|
|
268
|
+
code="nonzero_exit",
|
|
269
|
+
message=f"claude exited {run.exit_code}: {run.stderr.strip()[:200]}",
|
|
270
|
+
repair="Inspect the error; retry with a smaller request.",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def contract_changed_error() -> ErrorInfo:
|
|
275
|
+
"""Shared cli_contract_changed error, reused across every failure path so a
|
|
276
|
+
drift is reported identically whether it surfaces on the sync, envelope, or
|
|
277
|
+
async-job path."""
|
|
278
|
+
return ErrorInfo(
|
|
279
|
+
code="cli_contract_changed",
|
|
280
|
+
message="claude rejected a flag or value this plugin sent — its CLI "
|
|
281
|
+
"contract likely changed for your installed version.",
|
|
282
|
+
repair="Update cc-plugin-codex (or pin claude to a supported version); "
|
|
283
|
+
"run claude_status to check the version.",
|
|
284
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Single source of truth for the external `claude` CLI contract.
|
|
2
|
+
|
|
3
|
+
Every assumption this server makes about the `claude` CLI — its flags,
|
|
4
|
+
subcommands, JSON-envelope keys, accepted effort levels, supported major
|
|
5
|
+
versions, and the stderr phrasings that mean the contract drifted — lives here so
|
|
6
|
+
an upstream breaking change is a one-file, greppable, testable edit. See
|
|
7
|
+
COMPATIBILITY.md for the assumption -> upstream-source map.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
CLAUDE_BIN = "claude"
|
|
13
|
+
|
|
14
|
+
# Core invocation that CANNOT be dropped: -p (print mode) + JSON output. If these
|
|
15
|
+
# disappear upstream the server cannot function, so a run must fail loudly rather
|
|
16
|
+
# than silently degrade.
|
|
17
|
+
CORE_INVOCATION = ("-p", "--output-format", "json")
|
|
18
|
+
END_OF_OPTIONS = "--"
|
|
19
|
+
|
|
20
|
+
# Subcommands / probes (free; no paid call).
|
|
21
|
+
VERSION_ARGS = ("--version",)
|
|
22
|
+
AUTH_STATUS_ARGS = ("auth", "status", "--text")
|
|
23
|
+
HELP_ARGS = ("--help",)
|
|
24
|
+
|
|
25
|
+
# --- Flag classes (see Item 5 of the resilience plan / COMPATIBILITY.md) --------
|
|
26
|
+
# ALWAYS_SEND: guarantee-bearing flags, sent unconditionally and NEVER gated on
|
|
27
|
+
# `--help` parsing. If upstream removes/renames one, `claude` rejects it at
|
|
28
|
+
# arg-parse BEFORE any model call (zero spend) and classify_failure() labels it
|
|
29
|
+
# cli_contract_changed. Gating these on the (inherently fuzzy) --help parse could
|
|
30
|
+
# silently drop a security/cost/behavioral guarantee, so we never do. All are long
|
|
31
|
+
# flags (the diagnostic in claude_status checks them against parsed --help).
|
|
32
|
+
ALWAYS_SEND_FLAGS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"--output-format", # core JSON output
|
|
35
|
+
"--no-chrome", # no interactive picker hanging an unattended run
|
|
36
|
+
"--append-system-prompt", # the independent-critic guardrails
|
|
37
|
+
"--max-budget-usd", # best-effort spend stop threshold
|
|
38
|
+
"--tools", # read-only / no-tool guarantee
|
|
39
|
+
"--strict-mcp-config",
|
|
40
|
+
"--mcp-config", # strip the user's MCP fleet (security boundary)
|
|
41
|
+
"--setting-sources", # scoped-mode isolation
|
|
42
|
+
"--bare", # bare-mode isolation
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# HELP_GATED: dropping one only reduces depth or relies on a still-present primary
|
|
47
|
+
# guard — never a safety/cost regression. The value is whether the flag takes an
|
|
48
|
+
# argument (so the gate skips the value token too). These are the ONLY flags gated
|
|
49
|
+
# on `claude --help`; a false negative here merely drops a harmless flag.
|
|
50
|
+
HELP_GATED_FLAGS = {
|
|
51
|
+
"--effort": True, # reasoning depth only
|
|
52
|
+
"--model": True, # falls back to the configured default model
|
|
53
|
+
"--disallowed-tools": True, # defense-in-depth; --tools is the primary allowlist
|
|
54
|
+
"--no-session-persistence": False, # without it a session merely persists to disk
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Cache TTL for the `claude --help` probe, so a long-lived server re-probes after
|
|
58
|
+
# an in-place CLI upgrade instead of trusting a stale snapshot forever.
|
|
59
|
+
HELP_CACHE_TTL_SECONDS = 300
|
|
60
|
+
|
|
61
|
+
# --- Reasoning effort -----------------------------------------------------------
|
|
62
|
+
VALID_EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
63
|
+
DEFAULT_EFFORT = "xhigh"
|
|
64
|
+
|
|
65
|
+
# --- Supported `claude` major version(s) ----------------------------------------
|
|
66
|
+
# A set (not a single int) so a future major can be added without a code change,
|
|
67
|
+
# and overridable via env so a user can opt into an untested major themselves.
|
|
68
|
+
SUPPORTED_MAJORS = frozenset({2})
|
|
69
|
+
SUPPORTED_MAJORS_ENV = "CC_PLUGIN_CODEX_SUPPORTED_MAJORS"
|
|
70
|
+
|
|
71
|
+
# --- JSON envelope keys read from `claude -p --output-format json` ---------------
|
|
72
|
+
# normalize.py / apply_cost_usage parse these tolerantly with .get(); listing them
|
|
73
|
+
# here keeps the consumed surface greppable and gives the golden-envelope test a
|
|
74
|
+
# canonical reference.
|
|
75
|
+
SUCCESS_SUBTYPES = (None, "success")
|
|
76
|
+
ENVELOPE_KEYS = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"is_error",
|
|
79
|
+
"subtype",
|
|
80
|
+
"result",
|
|
81
|
+
"total_cost_usd",
|
|
82
|
+
"usage",
|
|
83
|
+
"session_id",
|
|
84
|
+
"modelUsage",
|
|
85
|
+
"permission_denials",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
USAGE_KEYS = frozenset(
|
|
89
|
+
{
|
|
90
|
+
"input_tokens",
|
|
91
|
+
"output_tokens",
|
|
92
|
+
"cache_read_input_tokens",
|
|
93
|
+
"cache_creation_input_tokens",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# --- Contract-drift stderr signatures -------------------------------------------
|
|
98
|
+
# Phrasings a CLI prints when it rejects a flag or value we sent. Matching any
|
|
99
|
+
# (case-insensitive) reclassifies an otherwise-generic failure as
|
|
100
|
+
# cli_contract_changed, telling the user the plugin needs an update for their CLI
|
|
101
|
+
# rather than leaving a confusing nonzero_exit.
|
|
102
|
+
CONTRACT_DRIFT_STDERR_PATTERNS = (
|
|
103
|
+
"unknown option",
|
|
104
|
+
"unknown flag",
|
|
105
|
+
"unknown argument",
|
|
106
|
+
"unrecognized option",
|
|
107
|
+
"unrecognized argument",
|
|
108
|
+
"no such option",
|
|
109
|
+
"invalid choice",
|
|
110
|
+
"invalid value",
|
|
111
|
+
"unexpected argument",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_contract_drift(*texts: str | None) -> bool:
|
|
116
|
+
"""Whether any provided text carries a contract-drift signature.
|
|
117
|
+
|
|
118
|
+
Used on every failure path (sync classify_failure, the zero-exit is_error
|
|
119
|
+
envelope, and the async job error) so drift is labelled consistently no matter
|
|
120
|
+
where `claude` surfaces it."""
|
|
121
|
+
blob = "\n".join(t for t in texts if t).lower()
|
|
122
|
+
return any(pattern in blob for pattern in CONTRACT_DRIFT_STDERR_PATTERNS)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Config knobs: env defaults, clamps, config_mode/access -> claude flags, critic prompt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from cc_plugin_codex import cli_contract
|
|
10
|
+
|
|
11
|
+
# Re-exported so existing `from ...config import VALID_EFFORTS` callers keep
|
|
12
|
+
# working; the canonical definition lives in cli_contract.
|
|
13
|
+
from cc_plugin_codex.cli_contract import DEFAULT_EFFORT, VALID_EFFORTS
|
|
14
|
+
|
|
15
|
+
EMPTY_MCP = '{"mcpServers":{}}'
|
|
16
|
+
|
|
17
|
+
MIN_BUDGET_USD, MAX_BUDGET_USD = 0.01, 5.00
|
|
18
|
+
MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS = 10, 600
|
|
19
|
+
DEFAULT_MAX_INPUT_BYTES = 200_000
|
|
20
|
+
DEFAULT_GIT_TIMEOUT_SECONDS = 60
|
|
21
|
+
|
|
22
|
+
__all__ = ["DEFAULT_EFFORT", "VALID_EFFORTS"] # re-exports; silence unused-import lints
|
|
23
|
+
|
|
24
|
+
INDEPENDENT_CRITIC_PROMPT = (
|
|
25
|
+
"You are being asked for an independent critique of Codex's work.\n"
|
|
26
|
+
"Do not assume Codex's approach is correct.\n"
|
|
27
|
+
"Prioritize correctness, safety, maintainability, and evidence over agreement "
|
|
28
|
+
"with Codex, the user, or project conventions.\n"
|
|
29
|
+
"Project instructions and memory may be present in your context, but if they "
|
|
30
|
+
"conflict with observable code behavior, tests, security, or the user's explicit "
|
|
31
|
+
"request, call out the conflict.\n"
|
|
32
|
+
"Do not rewrite or implement changes.\n"
|
|
33
|
+
"Return concrete findings only when you can tie them to evidence, such as a file, "
|
|
34
|
+
"line, diff hunk, command output, or stated assumption.\n"
|
|
35
|
+
"If the evidence is insufficient, say what is missing instead of guessing.\n"
|
|
36
|
+
"Avoid recursive handoffs; do not suggest asking another agent unless the user "
|
|
37
|
+
"explicitly requested that workflow."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Defaults:
|
|
43
|
+
config_mode: str
|
|
44
|
+
access: str
|
|
45
|
+
model: str | None
|
|
46
|
+
max_budget_usd: float
|
|
47
|
+
timeout_seconds: int
|
|
48
|
+
effort: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env_float(name: str, default: float) -> float:
|
|
52
|
+
raw = os.environ.get(name)
|
|
53
|
+
if raw is None:
|
|
54
|
+
return default
|
|
55
|
+
try:
|
|
56
|
+
return float(raw)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _env_int(name: str, default: int) -> int:
|
|
62
|
+
raw = os.environ.get(name)
|
|
63
|
+
if raw is None:
|
|
64
|
+
return default
|
|
65
|
+
try:
|
|
66
|
+
return int(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def defaults() -> Defaults:
|
|
72
|
+
return Defaults(
|
|
73
|
+
config_mode=os.environ.get("CC_PLUGIN_CODEX_CLAUDE_CONFIG", "inherit"),
|
|
74
|
+
access=os.environ.get("CC_PLUGIN_CODEX_ACCESS", "toolless"),
|
|
75
|
+
model=os.environ.get("CC_PLUGIN_CODEX_MODEL") or None,
|
|
76
|
+
max_budget_usd=_env_float("CC_PLUGIN_CODEX_MAX_BUDGET_USD", 1.00),
|
|
77
|
+
timeout_seconds=_env_int("CC_PLUGIN_CODEX_TIMEOUT_SECONDS", 180),
|
|
78
|
+
effort=sanitize_effort(os.environ.get("CC_PLUGIN_CODEX_EFFORT")),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sanitize_effort(value: str | None) -> str:
|
|
83
|
+
"""Normalize an effort value to a CLI-accepted level, falling back to the
|
|
84
|
+
default. An invalid env value must not break a paid call, so it degrades
|
|
85
|
+
rather than raising."""
|
|
86
|
+
return value if value in VALID_EFFORTS else DEFAULT_EFFORT
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def supported_majors() -> frozenset[int]:
|
|
90
|
+
"""The `claude` CLI major versions this server is built against.
|
|
91
|
+
|
|
92
|
+
Defaults to cli_contract.SUPPORTED_MAJORS; overridable via
|
|
93
|
+
CC_PLUGIN_CODEX_SUPPORTED_MAJORS (comma-separated ints) so a user can opt into
|
|
94
|
+
an untested major. Any parse error falls back to the built-in set rather than
|
|
95
|
+
raising."""
|
|
96
|
+
raw = os.environ.get(cli_contract.SUPPORTED_MAJORS_ENV)
|
|
97
|
+
if not raw:
|
|
98
|
+
return cli_contract.SUPPORTED_MAJORS
|
|
99
|
+
try:
|
|
100
|
+
parsed = frozenset(int(part) for part in raw.split(",") if part.strip())
|
|
101
|
+
except ValueError:
|
|
102
|
+
return cli_contract.SUPPORTED_MAJORS
|
|
103
|
+
return parsed or cli_contract.SUPPORTED_MAJORS
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def version_supported(version: str | None) -> bool | None:
|
|
107
|
+
"""Whether the installed `claude --version` major is in supported_majors().
|
|
108
|
+
|
|
109
|
+
Returns None when the version is unknown/unparseable (so callers can report
|
|
110
|
+
'unknown' rather than a false 'unsupported'). Advisory only: claude_status
|
|
111
|
+
surfaces a mismatch as a warning and never blocks paid calls on it."""
|
|
112
|
+
if not version:
|
|
113
|
+
return None
|
|
114
|
+
match = re.search(r"(\d+)\.\d+\.\d+", version)
|
|
115
|
+
if not match:
|
|
116
|
+
return None
|
|
117
|
+
return int(match.group(1)) in supported_majors()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clamp_budget(value: float) -> float:
|
|
121
|
+
return max(MIN_BUDGET_USD, min(MAX_BUDGET_USD, value))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def clamp_timeout(value: int) -> int:
|
|
125
|
+
return max(MIN_TIMEOUT_SECONDS, min(MAX_TIMEOUT_SECONDS, value))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def max_input_bytes() -> int:
|
|
129
|
+
return max(1_000, _env_int("CC_PLUGIN_CODEX_MAX_INPUT_BYTES", DEFAULT_MAX_INPUT_BYTES))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def git_timeout_seconds() -> int:
|
|
133
|
+
return max(1, _env_int("CC_PLUGIN_CODEX_GIT_TIMEOUT_SECONDS", DEFAULT_GIT_TIMEOUT_SECONDS))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def bare_available() -> bool:
|
|
137
|
+
return bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def config_mode_flags(mode: str) -> list[str]:
|
|
141
|
+
# All modes drop the user's MCP fleet (a reviewer never needs it, and it is a
|
|
142
|
+
# side-effect vector). inherit/scoped keep the user's login; bare needs an API key.
|
|
143
|
+
if mode == "inherit":
|
|
144
|
+
return ["--no-session-persistence", "--strict-mcp-config", "--mcp-config", EMPTY_MCP]
|
|
145
|
+
if mode == "scoped":
|
|
146
|
+
return [
|
|
147
|
+
"--setting-sources",
|
|
148
|
+
"project",
|
|
149
|
+
"--strict-mcp-config",
|
|
150
|
+
"--mcp-config",
|
|
151
|
+
EMPTY_MCP,
|
|
152
|
+
"--no-session-persistence",
|
|
153
|
+
]
|
|
154
|
+
if mode == "bare":
|
|
155
|
+
return [
|
|
156
|
+
"--bare",
|
|
157
|
+
"--no-session-persistence",
|
|
158
|
+
"--strict-mcp-config",
|
|
159
|
+
"--mcp-config",
|
|
160
|
+
EMPTY_MCP,
|
|
161
|
+
]
|
|
162
|
+
raise ValueError(f"unsupported config_mode: {mode}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def access_flags(access: str) -> list[str]:
|
|
166
|
+
if access == "toolless":
|
|
167
|
+
return ["--tools", ""]
|
|
168
|
+
if access == "readonly":
|
|
169
|
+
# --tools is the PRIMARY allowlist (read-only guarantee); --disallowed-tools is
|
|
170
|
+
# defense-in-depth only. Never widen --tools to include write/Bash tools.
|
|
171
|
+
return ["--tools", "Read,Grep,Glob", "--disallowed-tools", "Edit,Write,NotebookEdit,Bash"]
|
|
172
|
+
raise ValueError(f"unsupported access: {access}")
|