graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Hardened adapter for an authenticated Codex ChatGPT subscription CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from .claude_executor import AdapterError, _canonical_file, _file_sha256, _identifier
|
|
14
|
+
from .cli_identity import CliIdentityPrimitiveError, parse_semantic_version_output
|
|
15
|
+
from .contracts import CliIdentity, Effort, PermissionMode, ProviderId
|
|
16
|
+
from .process_runner import (
|
|
17
|
+
CliProcessError,
|
|
18
|
+
CliProcessResult,
|
|
19
|
+
QUOTA_MARKERS,
|
|
20
|
+
decode_cli_output,
|
|
21
|
+
run_cli_process,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ADAPTER_PROTOCOL_VERSION: Final = "1.0.0"
|
|
25
|
+
# Windows write authority depends on Codex's [windows] sandbox setting, which
|
|
26
|
+
# normally lives in user config. `--ignore-user-config` strips it, so a
|
|
27
|
+
# workspace-write execution must bind the sandbox mode explicitly in argv or
|
|
28
|
+
# every write tool call is denied under `-a never` and the edit becomes a
|
|
29
|
+
# silent no-op. The binding is passed on every platform for one deterministic
|
|
30
|
+
# argv contract; non-Windows Codex ignores the [windows] section.
|
|
31
|
+
WINDOWS_SANDBOX_MODE: Final = "elevated"
|
|
32
|
+
PREFLIGHT_TIMEOUT_SECONDS: Final = 15.0
|
|
33
|
+
EXECUTION_TIMEOUT_SECONDS: Final = 1_800.0
|
|
34
|
+
MAX_EVENT_COUNT: Final = 10_000
|
|
35
|
+
MAX_MESSAGE_LENGTH: Final = 1_048_576
|
|
36
|
+
MAX_TOKEN_COUNT: Final = 10_000_000
|
|
37
|
+
MAX_OUTPUT_SCHEMA_BYTES: Final = 65_536
|
|
38
|
+
_VERSION = re.compile(r"^codex-cli (0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\r?\n?$")
|
|
39
|
+
_ALLOWED_EVENTS: Final = frozenset(
|
|
40
|
+
{
|
|
41
|
+
"thread.started",
|
|
42
|
+
"turn.started",
|
|
43
|
+
"item.started",
|
|
44
|
+
"item.updated",
|
|
45
|
+
"item.completed",
|
|
46
|
+
"turn.completed",
|
|
47
|
+
"turn.failed",
|
|
48
|
+
"error",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
_TRANSPORT_ERRORS: Final = {
|
|
52
|
+
"timeout": "timeout",
|
|
53
|
+
"cancelled": "cancelled",
|
|
54
|
+
"response_limit": "response_limit",
|
|
55
|
+
"process_containment_unavailable": "containment",
|
|
56
|
+
"process_containment_failed": "containment",
|
|
57
|
+
}
|
|
58
|
+
_PREFLIGHT_WARNING_PREFIXES: Final = (
|
|
59
|
+
"WARNING: failed to clean up stale arg0 temp dirs:",
|
|
60
|
+
"WARNING: proceeding, even though we could not create PATH aliases:",
|
|
61
|
+
)
|
|
62
|
+
_CAPACITY_MESSAGE: Final = "selected model is at capacity. please try a different model."
|
|
63
|
+
_SHA256: Final = re.compile(r"^[0-9a-f]{64}$")
|
|
64
|
+
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class CodexExecutionResult:
|
|
69
|
+
effective_model: str
|
|
70
|
+
message: str = field(repr=False)
|
|
71
|
+
input_tokens: int | None
|
|
72
|
+
output_tokens: int | None
|
|
73
|
+
duration_seconds: float
|
|
74
|
+
input_sha256: str
|
|
75
|
+
stdout_sha256: str
|
|
76
|
+
stderr_sha256: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Transport = Callable[..., CliProcessResult]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_reparse(metadata: object) -> bool:
|
|
83
|
+
attributes = getattr(metadata, "st_file_attributes", 0)
|
|
84
|
+
return bool(attributes & _REPARSE_POINT)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _output_schema(
|
|
88
|
+
path: Path | None,
|
|
89
|
+
expected_sha256: str | None,
|
|
90
|
+
*,
|
|
91
|
+
workspace: Path,
|
|
92
|
+
) -> tuple[Path, str] | None:
|
|
93
|
+
if path is None and expected_sha256 is None:
|
|
94
|
+
return None
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(path, Path)
|
|
97
|
+
or not path.is_absolute()
|
|
98
|
+
or not isinstance(expected_sha256, str)
|
|
99
|
+
or _SHA256.fullmatch(expected_sha256) is None
|
|
100
|
+
):
|
|
101
|
+
raise AdapterError("request_invalid")
|
|
102
|
+
try:
|
|
103
|
+
metadata = path.lstat()
|
|
104
|
+
resolved = path.resolve(strict=True)
|
|
105
|
+
resolved_metadata = resolved.stat()
|
|
106
|
+
workspace_root = workspace.resolve(strict=True)
|
|
107
|
+
if (
|
|
108
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
109
|
+
or stat.S_ISLNK(metadata.st_mode)
|
|
110
|
+
or _is_reparse(metadata)
|
|
111
|
+
or not stat.S_ISREG(resolved_metadata.st_mode)
|
|
112
|
+
or _is_reparse(resolved_metadata)
|
|
113
|
+
or not 0 < metadata.st_size <= MAX_OUTPUT_SCHEMA_BYTES
|
|
114
|
+
or resolved.is_relative_to(workspace_root)
|
|
115
|
+
):
|
|
116
|
+
raise OSError
|
|
117
|
+
body = resolved.read_bytes()
|
|
118
|
+
parsed = json.loads(body.decode("utf-8"))
|
|
119
|
+
except (OSError, RuntimeError, UnicodeDecodeError, json.JSONDecodeError):
|
|
120
|
+
raise AdapterError("response_contract_invalid") from None
|
|
121
|
+
if not isinstance(parsed, dict):
|
|
122
|
+
raise AdapterError("response_contract_invalid")
|
|
123
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
124
|
+
if digest != expected_sha256:
|
|
125
|
+
raise AdapterError("response_contract_mismatch")
|
|
126
|
+
return resolved, digest
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _invoke(transport: Transport, **kwargs: object) -> CliProcessResult:
|
|
130
|
+
try:
|
|
131
|
+
result = transport(**kwargs)
|
|
132
|
+
except CliProcessError as exc:
|
|
133
|
+
raise AdapterError(
|
|
134
|
+
_TRANSPORT_ERRORS.get(exc.code, "unavailable"),
|
|
135
|
+
process_diagnostics=exc.diagnostics,
|
|
136
|
+
) from None
|
|
137
|
+
except AdapterError:
|
|
138
|
+
raise
|
|
139
|
+
except Exception:
|
|
140
|
+
raise AdapterError("unavailable") from None
|
|
141
|
+
if not isinstance(result, CliProcessResult) or result.returncode != 0:
|
|
142
|
+
raise AdapterError("unavailable")
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def preflight_codex(
|
|
147
|
+
*,
|
|
148
|
+
executable: Path,
|
|
149
|
+
workspace: Path,
|
|
150
|
+
credential_home: Path | None,
|
|
151
|
+
transport: Transport = run_cli_process,
|
|
152
|
+
) -> CliIdentity:
|
|
153
|
+
"""Verify executable identity and ChatGPT subscription authentication."""
|
|
154
|
+
resolved = _canonical_file(executable, workspace=workspace)
|
|
155
|
+
executable_digest = _file_sha256(resolved)
|
|
156
|
+
common = {
|
|
157
|
+
"cwd": workspace,
|
|
158
|
+
"provider": ProviderId.CODEX,
|
|
159
|
+
"credential_home": credential_home,
|
|
160
|
+
"timeout_seconds": PREFLIGHT_TIMEOUT_SECONDS,
|
|
161
|
+
}
|
|
162
|
+
version_result = _invoke(
|
|
163
|
+
transport, argv=(str(resolved), "--version"), stdin=b"", **common
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
version_text = decode_cli_output(version_result.stdout)
|
|
167
|
+
except CliProcessError:
|
|
168
|
+
raise AdapterError("version") from None
|
|
169
|
+
try:
|
|
170
|
+
version = parse_semantic_version_output(version_text, _VERSION)
|
|
171
|
+
except CliIdentityPrimitiveError:
|
|
172
|
+
raise AdapterError("version")
|
|
173
|
+
status_result = _invoke(
|
|
174
|
+
transport, argv=(str(resolved), "login", "status"), stdin=b"", **common
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
streams = (
|
|
178
|
+
decode_cli_output(status_result.stdout),
|
|
179
|
+
decode_cli_output(status_result.stderr),
|
|
180
|
+
)
|
|
181
|
+
except CliProcessError:
|
|
182
|
+
raise AdapterError("auth_required") from None
|
|
183
|
+
lines = [line.strip() for stream in streams for line in stream.splitlines() if line.strip()]
|
|
184
|
+
status_count = lines.count("Logged in using ChatGPT")
|
|
185
|
+
noise = [line for line in lines if line != "Logged in using ChatGPT"]
|
|
186
|
+
if status_count != 1 or any(
|
|
187
|
+
not line.startswith(_PREFLIGHT_WARNING_PREFIXES) for line in noise
|
|
188
|
+
):
|
|
189
|
+
raise AdapterError("auth_required")
|
|
190
|
+
if _file_sha256(resolved) != executable_digest:
|
|
191
|
+
raise AdapterError("version")
|
|
192
|
+
return CliIdentity(
|
|
193
|
+
ProviderId.CODEX,
|
|
194
|
+
executable_digest,
|
|
195
|
+
version,
|
|
196
|
+
ADAPTER_PROTOCOL_VERSION,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _token(value: object) -> int:
|
|
201
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= MAX_TOKEN_COUNT:
|
|
202
|
+
raise AdapterError("protocol")
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _failure_code(event: dict[str, object]) -> str:
|
|
207
|
+
text = json.dumps(event, ensure_ascii=True, separators=(",", ":")).lower()
|
|
208
|
+
if "auth" in text or "login" in text or "unauthorized" in text:
|
|
209
|
+
return "auth_required"
|
|
210
|
+
if any(marker in text for marker in QUOTA_MARKERS):
|
|
211
|
+
return "quota"
|
|
212
|
+
return "unavailable"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _parse_jsonl(
|
|
216
|
+
stdout: bytes,
|
|
217
|
+
expected_model: str,
|
|
218
|
+
*,
|
|
219
|
+
final_message_only: bool = False,
|
|
220
|
+
) -> tuple[str, str, int, int]:
|
|
221
|
+
try:
|
|
222
|
+
text = decode_cli_output(stdout)
|
|
223
|
+
except CliProcessError:
|
|
224
|
+
raise AdapterError("protocol") from None
|
|
225
|
+
lines = text.splitlines()
|
|
226
|
+
if not lines or len(lines) > MAX_EVENT_COUNT or any(not line.strip() for line in lines):
|
|
227
|
+
raise AdapterError("protocol")
|
|
228
|
+
messages: list[str] = []
|
|
229
|
+
completion: dict[str, object] | None = None
|
|
230
|
+
terminal_index: int | None = None
|
|
231
|
+
for index, line in enumerate(lines):
|
|
232
|
+
try:
|
|
233
|
+
event = json.loads(line)
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
raise AdapterError("protocol") from None
|
|
236
|
+
if not isinstance(event, dict) or event.get("type") not in _ALLOWED_EVENTS:
|
|
237
|
+
raise AdapterError("protocol")
|
|
238
|
+
event_type = event["type"]
|
|
239
|
+
if event_type in {"turn.failed", "error"}:
|
|
240
|
+
if index != len(lines) - 1:
|
|
241
|
+
raise AdapterError("protocol")
|
|
242
|
+
raise AdapterError(_failure_code(event))
|
|
243
|
+
if event_type == "item.completed":
|
|
244
|
+
item = event.get("item")
|
|
245
|
+
if isinstance(item, dict) and item.get("type") == "agent_message":
|
|
246
|
+
message = item.get("text")
|
|
247
|
+
if not isinstance(message, str) or not message or len(message) > MAX_MESSAGE_LENGTH:
|
|
248
|
+
raise AdapterError("protocol")
|
|
249
|
+
messages.append(message)
|
|
250
|
+
if event_type == "turn.completed":
|
|
251
|
+
if completion is not None:
|
|
252
|
+
raise AdapterError("protocol")
|
|
253
|
+
completion = event
|
|
254
|
+
terminal_index = index
|
|
255
|
+
if completion is None or terminal_index != len(lines) - 1:
|
|
256
|
+
raise AdapterError("protocol")
|
|
257
|
+
effective_model = completion.get("model")
|
|
258
|
+
if effective_model is None:
|
|
259
|
+
# Codex's documented exec JSONL terminal event does not echo the model.
|
|
260
|
+
# Identity remains bound by the full requested slug, strict config, and
|
|
261
|
+
# the immutable capability snapshot. A future conflicting echo fails.
|
|
262
|
+
effective_model = expected_model
|
|
263
|
+
else:
|
|
264
|
+
if not isinstance(effective_model, str):
|
|
265
|
+
raise AdapterError("model_identity_unverified")
|
|
266
|
+
try:
|
|
267
|
+
_identifier(effective_model)
|
|
268
|
+
except AdapterError:
|
|
269
|
+
raise AdapterError("model_identity_unverified") from None
|
|
270
|
+
if effective_model != expected_model:
|
|
271
|
+
raise AdapterError("model_mismatch")
|
|
272
|
+
usage = completion.get("usage")
|
|
273
|
+
if not isinstance(usage, dict):
|
|
274
|
+
raise AdapterError("protocol")
|
|
275
|
+
message = messages[-1] if final_message_only else "\n".join(messages)
|
|
276
|
+
if not message:
|
|
277
|
+
raise AdapterError("protocol")
|
|
278
|
+
if message.strip().lower() == _CAPACITY_MESSAGE:
|
|
279
|
+
raise AdapterError("capacity_unavailable")
|
|
280
|
+
return (
|
|
281
|
+
message,
|
|
282
|
+
effective_model,
|
|
283
|
+
_token(usage.get("input_tokens")),
|
|
284
|
+
_token(usage.get("output_tokens")),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def execute_codex(
|
|
289
|
+
*,
|
|
290
|
+
executable: Path,
|
|
291
|
+
workspace: Path,
|
|
292
|
+
credential_home: Path | None,
|
|
293
|
+
prompt: bytes,
|
|
294
|
+
requested_model: str,
|
|
295
|
+
expected_effective_model: str,
|
|
296
|
+
effort: Effort,
|
|
297
|
+
permission_mode: PermissionMode,
|
|
298
|
+
output_schema_path: Path | None = None,
|
|
299
|
+
output_schema_sha256: str | None = None,
|
|
300
|
+
transport: Transport = run_cli_process,
|
|
301
|
+
timeout_seconds: float = EXECUTION_TIMEOUT_SECONDS,
|
|
302
|
+
) -> CodexExecutionResult:
|
|
303
|
+
"""Execute exactly one ephemeral, sandboxed Codex task."""
|
|
304
|
+
resolved = _canonical_file(executable, workspace=workspace)
|
|
305
|
+
requested = _identifier(requested_model)
|
|
306
|
+
expected = _identifier(expected_effective_model)
|
|
307
|
+
try:
|
|
308
|
+
normalized_effort = Effort(effort)
|
|
309
|
+
permission = PermissionMode(permission_mode)
|
|
310
|
+
except (TypeError, ValueError):
|
|
311
|
+
raise AdapterError("request_invalid") from None
|
|
312
|
+
if normalized_effort in {Effort.DEFAULT, Effort.MAX}:
|
|
313
|
+
raise AdapterError("request_invalid")
|
|
314
|
+
schema = _output_schema(
|
|
315
|
+
output_schema_path,
|
|
316
|
+
output_schema_sha256,
|
|
317
|
+
workspace=workspace,
|
|
318
|
+
)
|
|
319
|
+
sandbox = "workspace-write" if permission is PermissionMode.WORKSPACE_WRITE else "read-only"
|
|
320
|
+
argv_prefix = (
|
|
321
|
+
str(resolved),
|
|
322
|
+
"--strict-config",
|
|
323
|
+
"-a",
|
|
324
|
+
"never",
|
|
325
|
+
"-s",
|
|
326
|
+
sandbox,
|
|
327
|
+
"-C",
|
|
328
|
+
str(workspace.resolve()),
|
|
329
|
+
"-m",
|
|
330
|
+
requested,
|
|
331
|
+
"-c",
|
|
332
|
+
f'model_reasoning_effort="{normalized_effort.value}"',
|
|
333
|
+
*(
|
|
334
|
+
("-c", f'windows.sandbox="{WINDOWS_SANDBOX_MODE}"')
|
|
335
|
+
if permission is PermissionMode.WORKSPACE_WRITE
|
|
336
|
+
else ()
|
|
337
|
+
),
|
|
338
|
+
"exec",
|
|
339
|
+
"--json",
|
|
340
|
+
"--ephemeral",
|
|
341
|
+
"--ignore-user-config",
|
|
342
|
+
"--ignore-rules",
|
|
343
|
+
)
|
|
344
|
+
argv = (
|
|
345
|
+
*argv_prefix,
|
|
346
|
+
*(("--output-schema", str(schema[0])) if schema is not None else ()),
|
|
347
|
+
"-",
|
|
348
|
+
)
|
|
349
|
+
result = _invoke(
|
|
350
|
+
transport,
|
|
351
|
+
argv=argv,
|
|
352
|
+
cwd=workspace,
|
|
353
|
+
stdin=prompt,
|
|
354
|
+
provider=ProviderId.CODEX,
|
|
355
|
+
credential_home=credential_home,
|
|
356
|
+
timeout_seconds=timeout_seconds,
|
|
357
|
+
)
|
|
358
|
+
if schema is not None:
|
|
359
|
+
try:
|
|
360
|
+
current = _output_schema(
|
|
361
|
+
schema[0],
|
|
362
|
+
schema[1],
|
|
363
|
+
workspace=workspace,
|
|
364
|
+
)
|
|
365
|
+
except AdapterError:
|
|
366
|
+
raise AdapterError("response_contract_changed") from None
|
|
367
|
+
if current is None or current[1] != schema[1]:
|
|
368
|
+
raise AdapterError("response_contract_changed")
|
|
369
|
+
message, effective_model, input_tokens, output_tokens = _parse_jsonl(
|
|
370
|
+
result.stdout,
|
|
371
|
+
expected,
|
|
372
|
+
final_message_only=schema is not None,
|
|
373
|
+
)
|
|
374
|
+
return CodexExecutionResult(
|
|
375
|
+
effective_model,
|
|
376
|
+
message,
|
|
377
|
+
input_tokens,
|
|
378
|
+
output_tokens,
|
|
379
|
+
result.duration_seconds,
|
|
380
|
+
result.input_sha256,
|
|
381
|
+
result.stdout_sha256,
|
|
382
|
+
result.stderr_sha256,
|
|
383
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Bounded, non-inference Codex CLI lifecycle observation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from .cli_identity import CliIdentityPrimitiveError, canonical_executable, executable_sha256, parse_semantic_version_output
|
|
12
|
+
from .lifecycle import LifecycleProviderId, ProviderRuntimeIdentity, RuntimeKind
|
|
13
|
+
from .probe_runner import ProviderProbeError, run_process_probe
|
|
14
|
+
from .process_runner import CliProcessError, CliProcessResult, decode_cli_output
|
|
15
|
+
|
|
16
|
+
_VERSION: Final = re.compile(r"^codex-cli (0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\r?\n?$")
|
|
17
|
+
_GLOBAL_FLAGS: Final = frozenset({"--strict-config", "-a", "-s", "-C", "-m", "-c"})
|
|
18
|
+
_EXEC_FLAGS: Final = frozenset(
|
|
19
|
+
{"--json", "--ephemeral", "--ignore-user-config", "--ignore-rules"}
|
|
20
|
+
)
|
|
21
|
+
_GLOBAL_FLAG_PATTERNS: Final = tuple(
|
|
22
|
+
re.compile(rf"(?<![A-Za-z0-9_-]){re.escape(flag)}(?![A-Za-z0-9_-])")
|
|
23
|
+
for flag in sorted(_GLOBAL_FLAGS)
|
|
24
|
+
)
|
|
25
|
+
_EXEC_FLAG_PATTERNS: Final = tuple(
|
|
26
|
+
re.compile(rf"(?<![A-Za-z0-9_-]){re.escape(flag)}(?![A-Za-z0-9_-])")
|
|
27
|
+
for flag in sorted(_EXEC_FLAGS)
|
|
28
|
+
)
|
|
29
|
+
_WARNING_PREFIXES: Final = ("WARNING: failed to clean up stale arg0 temp dirs:", "WARNING: proceeding, even though we could not create PATH aliases:")
|
|
30
|
+
ProcessProbe = Callable[..., CliProcessResult]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _streams(result: CliProcessResult, code: str) -> tuple[str, str]:
|
|
34
|
+
if not isinstance(result, CliProcessResult) or result.returncode != 0:
|
|
35
|
+
raise ProviderProbeError(code)
|
|
36
|
+
try:
|
|
37
|
+
return decode_cli_output(result.stdout), decode_cli_output(result.stderr)
|
|
38
|
+
except CliProcessError:
|
|
39
|
+
raise ProviderProbeError(code) from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def observe_codex(
|
|
43
|
+
*, executable: Path, workspace: Path, credential_home: Path | None,
|
|
44
|
+
observed_at: int, policy_version: str, timeout_seconds: float = 15.0,
|
|
45
|
+
transport: ProcessProbe = run_process_probe,
|
|
46
|
+
clock: Callable[[], float] = time.monotonic,
|
|
47
|
+
) -> ProviderRuntimeIdentity:
|
|
48
|
+
"""Observe one Codex CLI through fixed local metadata commands only."""
|
|
49
|
+
if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) or not math.isfinite(timeout_seconds) or not 0.1 <= timeout_seconds <= 30:
|
|
50
|
+
raise ProviderProbeError("probe_request_invalid")
|
|
51
|
+
try:
|
|
52
|
+
resolved = canonical_executable(executable, workspace=workspace)
|
|
53
|
+
runtime_digest = executable_sha256(resolved)
|
|
54
|
+
except CliIdentityPrimitiveError:
|
|
55
|
+
raise ProviderProbeError("probe_executable_invalid") from None
|
|
56
|
+
deadline = clock() + float(timeout_seconds)
|
|
57
|
+
|
|
58
|
+
def call(*args: str) -> CliProcessResult:
|
|
59
|
+
remaining = deadline - clock()
|
|
60
|
+
if remaining <= 0:
|
|
61
|
+
raise ProviderProbeError("probe_timeout")
|
|
62
|
+
try:
|
|
63
|
+
return transport(argv=(str(resolved), *args), cwd=workspace, provider=LifecycleProviderId.CODEX, credential_home=credential_home, timeout_seconds=remaining)
|
|
64
|
+
except ProviderProbeError:
|
|
65
|
+
raise
|
|
66
|
+
except Exception:
|
|
67
|
+
raise ProviderProbeError("probe_failed") from None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
version = parse_semantic_version_output(_streams(call("--version"), "probe_version_invalid")[0], _VERSION)
|
|
71
|
+
except CliIdentityPrimitiveError:
|
|
72
|
+
raise ProviderProbeError("probe_version_invalid") from None
|
|
73
|
+
help_stdout, help_stderr = _streams(call("--help"), "probe_capability_missing")
|
|
74
|
+
global_help = help_stdout + "\n" + help_stderr
|
|
75
|
+
exec_stdout, exec_stderr = _streams(call("exec", "--help"), "probe_capability_missing")
|
|
76
|
+
exec_help = exec_stdout + "\n" + exec_stderr
|
|
77
|
+
if not all(pattern.search(global_help) for pattern in _GLOBAL_FLAG_PATTERNS) or not all(
|
|
78
|
+
pattern.search(exec_help) for pattern in _EXEC_FLAG_PATTERNS
|
|
79
|
+
):
|
|
80
|
+
raise ProviderProbeError("probe_capability_missing")
|
|
81
|
+
stdout, stderr = _streams(call("login", "status"), "probe_auth_unhealthy")
|
|
82
|
+
lines = [line.strip() for stream in (stdout, stderr) for line in stream.splitlines() if line.strip()]
|
|
83
|
+
if lines.count("Logged in using ChatGPT") != 1 or any(line != "Logged in using ChatGPT" and not line.startswith(_WARNING_PREFIXES) for line in lines):
|
|
84
|
+
raise ProviderProbeError("probe_auth_unhealthy")
|
|
85
|
+
try:
|
|
86
|
+
if executable_sha256(resolved) != runtime_digest:
|
|
87
|
+
raise ProviderProbeError("probe_identity_changed")
|
|
88
|
+
except CliIdentityPrimitiveError:
|
|
89
|
+
raise ProviderProbeError("probe_identity_changed") from None
|
|
90
|
+
try:
|
|
91
|
+
return ProviderRuntimeIdentity(LifecycleProviderId.CODEX, RuntimeKind.LOCAL_CLI, version, runtime_digest, None, None, ("credential_health", "structured_output", "version"), policy_version, observed_at)
|
|
92
|
+
except ValueError:
|
|
93
|
+
raise ProviderProbeError("probe_request_invalid") from None
|