foundry-mcp 0.8.22__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.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Codex CLI provider implementation.
|
|
3
|
+
|
|
4
|
+
Wraps the `codex exec` command to satisfy the ProviderContext contract,
|
|
5
|
+
including availability checks, request validation, JSONL parsing, and
|
|
6
|
+
token usage normalization. Enforces read-only restrictions via native
|
|
7
|
+
OS-level sandboxing (--sandbox read-only flag).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from .base import (
|
|
19
|
+
ProviderCapability,
|
|
20
|
+
ProviderContext,
|
|
21
|
+
ProviderExecutionError,
|
|
22
|
+
ProviderHooks,
|
|
23
|
+
ProviderMetadata,
|
|
24
|
+
ProviderRequest,
|
|
25
|
+
ProviderResult,
|
|
26
|
+
ProviderStatus,
|
|
27
|
+
ProviderTimeoutError,
|
|
28
|
+
ProviderUnavailableError,
|
|
29
|
+
StreamChunk,
|
|
30
|
+
TokenUsage,
|
|
31
|
+
)
|
|
32
|
+
from .detectors import detect_provider_availability
|
|
33
|
+
from .registry import register_provider
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
DEFAULT_BINARY = "codex"
|
|
38
|
+
DEFAULT_TIMEOUT_SECONDS = 360
|
|
39
|
+
AVAILABILITY_OVERRIDE_ENV = "CODEX_CLI_AVAILABLE_OVERRIDE"
|
|
40
|
+
CUSTOM_BINARY_ENV = "CODEX_CLI_BINARY"
|
|
41
|
+
|
|
42
|
+
# Read-only operations allowed by Codex --sandbox read-only mode
|
|
43
|
+
# Note: These are enforced natively by OS-level sandboxing, not by this wrapper
|
|
44
|
+
# macOS: Seatbelt | Linux: Landlock + seccomp | Windows: Restricted Token
|
|
45
|
+
SANDBOX_ALLOWED_OPERATIONS = [
|
|
46
|
+
# File operations (read-only)
|
|
47
|
+
"Read",
|
|
48
|
+
"Grep",
|
|
49
|
+
"Glob",
|
|
50
|
+
"List",
|
|
51
|
+
# Task delegation
|
|
52
|
+
"Task",
|
|
53
|
+
# Shell commands - file viewing
|
|
54
|
+
"Shell(cat)",
|
|
55
|
+
"Shell(head)",
|
|
56
|
+
"Shell(tail)",
|
|
57
|
+
"Shell(bat)",
|
|
58
|
+
"Shell(less)",
|
|
59
|
+
"Shell(more)",
|
|
60
|
+
# Shell commands - directory listing/navigation
|
|
61
|
+
"Shell(ls)",
|
|
62
|
+
"Shell(tree)",
|
|
63
|
+
"Shell(pwd)",
|
|
64
|
+
"Shell(which)",
|
|
65
|
+
"Shell(whereis)",
|
|
66
|
+
# Shell commands - search/find
|
|
67
|
+
"Shell(grep)",
|
|
68
|
+
"Shell(rg)",
|
|
69
|
+
"Shell(ag)",
|
|
70
|
+
"Shell(find)",
|
|
71
|
+
"Shell(fd)",
|
|
72
|
+
"Shell(locate)",
|
|
73
|
+
# Shell commands - git operations (read-only)
|
|
74
|
+
"Shell(git log)",
|
|
75
|
+
"Shell(git show)",
|
|
76
|
+
"Shell(git diff)",
|
|
77
|
+
"Shell(git status)",
|
|
78
|
+
"Shell(git grep)",
|
|
79
|
+
"Shell(git blame)",
|
|
80
|
+
"Shell(git branch)",
|
|
81
|
+
"Shell(git rev-parse)",
|
|
82
|
+
"Shell(git describe)",
|
|
83
|
+
"Shell(git ls-tree)",
|
|
84
|
+
"Shell(git ls-files)",
|
|
85
|
+
# Shell commands - text processing
|
|
86
|
+
"Shell(wc)",
|
|
87
|
+
"Shell(cut)",
|
|
88
|
+
"Shell(paste)",
|
|
89
|
+
"Shell(column)",
|
|
90
|
+
"Shell(sort)",
|
|
91
|
+
"Shell(uniq)",
|
|
92
|
+
"Shell(diff)",
|
|
93
|
+
# Shell commands - data formats
|
|
94
|
+
"Shell(jq)",
|
|
95
|
+
"Shell(yq)",
|
|
96
|
+
"Shell(xmllint)",
|
|
97
|
+
# Shell commands - file analysis
|
|
98
|
+
"Shell(file)",
|
|
99
|
+
"Shell(stat)",
|
|
100
|
+
"Shell(du)",
|
|
101
|
+
"Shell(df)",
|
|
102
|
+
"Shell(lsof)",
|
|
103
|
+
# Shell commands - checksums/hashing
|
|
104
|
+
"Shell(md5sum)",
|
|
105
|
+
"Shell(shasum)",
|
|
106
|
+
"Shell(sha256sum)",
|
|
107
|
+
"Shell(sha512sum)",
|
|
108
|
+
"Shell(cksum)",
|
|
109
|
+
# Shell commands - process inspection
|
|
110
|
+
"Shell(ps)",
|
|
111
|
+
"Shell(top)",
|
|
112
|
+
"Shell(htop)",
|
|
113
|
+
# Shell commands - system information
|
|
114
|
+
"Shell(uname)",
|
|
115
|
+
"Shell(hostname)",
|
|
116
|
+
"Shell(whoami)",
|
|
117
|
+
"Shell(id)",
|
|
118
|
+
"Shell(date)",
|
|
119
|
+
"Shell(uptime)",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# Operations blocked by Codex --sandbox read-only mode
|
|
123
|
+
SANDBOX_BLOCKED_OPERATIONS = [
|
|
124
|
+
"Write",
|
|
125
|
+
"Edit",
|
|
126
|
+
"Patch",
|
|
127
|
+
"Delete",
|
|
128
|
+
# Web operations (data exfiltration risk)
|
|
129
|
+
"WebFetch",
|
|
130
|
+
# Dangerous file operations
|
|
131
|
+
"Shell(rm)",
|
|
132
|
+
"Shell(rmdir)",
|
|
133
|
+
"Shell(dd)",
|
|
134
|
+
"Shell(mkfs)",
|
|
135
|
+
"Shell(fdisk)",
|
|
136
|
+
"Shell(shred)",
|
|
137
|
+
# File modifications
|
|
138
|
+
"Shell(touch)",
|
|
139
|
+
"Shell(mkdir)",
|
|
140
|
+
"Shell(mv)",
|
|
141
|
+
"Shell(cp)",
|
|
142
|
+
"Shell(chmod)",
|
|
143
|
+
"Shell(chown)",
|
|
144
|
+
"Shell(chgrp)",
|
|
145
|
+
"Shell(sed)",
|
|
146
|
+
"Shell(awk)",
|
|
147
|
+
"Shell(tee)",
|
|
148
|
+
# Git write operations
|
|
149
|
+
"Shell(git add)",
|
|
150
|
+
"Shell(git commit)",
|
|
151
|
+
"Shell(git push)",
|
|
152
|
+
"Shell(git pull)",
|
|
153
|
+
"Shell(git merge)",
|
|
154
|
+
"Shell(git rebase)",
|
|
155
|
+
"Shell(git reset)",
|
|
156
|
+
"Shell(git checkout)",
|
|
157
|
+
"Shell(git stash)",
|
|
158
|
+
"Shell(git cherry-pick)",
|
|
159
|
+
# Package installations
|
|
160
|
+
"Shell(npm install)",
|
|
161
|
+
"Shell(pip install)",
|
|
162
|
+
"Shell(apt install)",
|
|
163
|
+
"Shell(apt-get install)",
|
|
164
|
+
"Shell(brew install)",
|
|
165
|
+
"Shell(yum install)",
|
|
166
|
+
"Shell(dnf install)",
|
|
167
|
+
"Shell(cargo install)",
|
|
168
|
+
# System operations
|
|
169
|
+
"Shell(sudo)",
|
|
170
|
+
"Shell(su)",
|
|
171
|
+
"Shell(halt)",
|
|
172
|
+
"Shell(reboot)",
|
|
173
|
+
"Shell(shutdown)",
|
|
174
|
+
"Shell(systemctl)",
|
|
175
|
+
"Shell(service)",
|
|
176
|
+
# Network write operations
|
|
177
|
+
"Shell(curl -X POST)",
|
|
178
|
+
"Shell(curl -X PUT)",
|
|
179
|
+
"Shell(curl -X DELETE)",
|
|
180
|
+
"Shell(wget)",
|
|
181
|
+
"Shell(scp)",
|
|
182
|
+
"Shell(rsync)",
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
# System prompt warning about Codex sandbox restrictions
|
|
186
|
+
SANDBOX_WARNING = """
|
|
187
|
+
IMPORTANT SECURITY NOTE: This session runs with Codex CLI's native --sandbox read-only mode:
|
|
188
|
+
1. Native OS-level sandboxing enforced by the operating system:
|
|
189
|
+
- macOS: Seatbelt sandbox policy
|
|
190
|
+
- Linux: Landlock LSM + seccomp filters
|
|
191
|
+
- Windows: Restricted token + job objects
|
|
192
|
+
2. Only read operations are permitted - writes are blocked at the OS level
|
|
193
|
+
3. Shell commands are restricted to read-only operations by the sandbox
|
|
194
|
+
4. The sandbox is enforced by the Codex CLI itself, not just tool filtering
|
|
195
|
+
5. This is the most robust security model - cannot be bypassed by piped commands or escapes
|
|
196
|
+
6. Attempts to write files or modify system state will be blocked by the OS
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class RunnerProtocol(Protocol):
|
|
201
|
+
"""Callable signature used for executing Codex CLI commands."""
|
|
202
|
+
|
|
203
|
+
def __call__(
|
|
204
|
+
self,
|
|
205
|
+
command: Sequence[str],
|
|
206
|
+
*,
|
|
207
|
+
timeout: Optional[int] = None,
|
|
208
|
+
env: Optional[Dict[str, str]] = None,
|
|
209
|
+
input_data: Optional[str] = None,
|
|
210
|
+
) -> subprocess.CompletedProcess[str]:
|
|
211
|
+
raise NotImplementedError
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _default_runner(
|
|
215
|
+
command: Sequence[str],
|
|
216
|
+
*,
|
|
217
|
+
timeout: Optional[int] = None,
|
|
218
|
+
env: Optional[Dict[str, str]] = None,
|
|
219
|
+
input_data: Optional[str] = None,
|
|
220
|
+
) -> subprocess.CompletedProcess[str]:
|
|
221
|
+
"""Invoke the Codex CLI via subprocess."""
|
|
222
|
+
return subprocess.run( # noqa: S603,S607 - intentional CLI invocation
|
|
223
|
+
list(command),
|
|
224
|
+
capture_output=True,
|
|
225
|
+
text=True,
|
|
226
|
+
input=input_data,
|
|
227
|
+
timeout=timeout,
|
|
228
|
+
env=env,
|
|
229
|
+
check=False,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
CODEX_METADATA = ProviderMetadata(
|
|
234
|
+
provider_id="codex",
|
|
235
|
+
display_name="OpenAI Codex CLI",
|
|
236
|
+
models=[], # Model validation delegated to CLI
|
|
237
|
+
default_model="gpt-5.2",
|
|
238
|
+
capabilities={ProviderCapability.TEXT, ProviderCapability.STREAMING, ProviderCapability.FUNCTION_CALLING},
|
|
239
|
+
security_flags={"writes_allowed": False, "read_only": True, "sandbox": "read-only"},
|
|
240
|
+
extra={
|
|
241
|
+
"cli": "codex",
|
|
242
|
+
"command": "codex exec",
|
|
243
|
+
"allowed_operations": SANDBOX_ALLOWED_OPERATIONS,
|
|
244
|
+
"blocked_operations": SANDBOX_BLOCKED_OPERATIONS,
|
|
245
|
+
"os_level_sandboxing": True,
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class CodexProvider(ProviderContext):
|
|
251
|
+
"""ProviderContext implementation backed by the Codex CLI with OS-level read-only sandboxing."""
|
|
252
|
+
|
|
253
|
+
# Environment variables that must be unset for Codex CLI to work properly
|
|
254
|
+
# These interfere with Codex's own API configuration
|
|
255
|
+
_UNSET_ENV_VARS = ("OPENAI_API_KEY", "OPENAI_BASE_URL")
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
metadata: ProviderMetadata,
|
|
260
|
+
hooks: ProviderHooks,
|
|
261
|
+
*,
|
|
262
|
+
model: Optional[str] = None,
|
|
263
|
+
binary: Optional[str] = None,
|
|
264
|
+
runner: Optional[RunnerProtocol] = None,
|
|
265
|
+
env: Optional[Dict[str, str]] = None,
|
|
266
|
+
timeout: Optional[int] = None,
|
|
267
|
+
):
|
|
268
|
+
super().__init__(metadata, hooks)
|
|
269
|
+
self._runner = runner or _default_runner
|
|
270
|
+
self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
|
|
271
|
+
self._env = self._prepare_subprocess_env(env)
|
|
272
|
+
self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
|
|
273
|
+
self._model = model or metadata.default_model or "gpt-5.2"
|
|
274
|
+
|
|
275
|
+
def _prepare_subprocess_env(self, custom_env: Optional[Dict[str, str]]) -> Dict[str, str]:
|
|
276
|
+
"""
|
|
277
|
+
Prepare environment variables for subprocess execution.
|
|
278
|
+
|
|
279
|
+
Codex CLI uses its own authentication and must not have OPENAI_API_KEY
|
|
280
|
+
or OPENAI_BASE_URL set, as these interfere with its internal API routing.
|
|
281
|
+
"""
|
|
282
|
+
# Start with current environment
|
|
283
|
+
subprocess_env = os.environ.copy()
|
|
284
|
+
|
|
285
|
+
# Remove variables that interfere with Codex CLI
|
|
286
|
+
for var in self._UNSET_ENV_VARS:
|
|
287
|
+
subprocess_env.pop(var, None)
|
|
288
|
+
|
|
289
|
+
# Merge custom environment if provided
|
|
290
|
+
if custom_env:
|
|
291
|
+
subprocess_env.update(custom_env)
|
|
292
|
+
|
|
293
|
+
return subprocess_env
|
|
294
|
+
|
|
295
|
+
def _validate_request(self, request: ProviderRequest) -> None:
|
|
296
|
+
"""Validate and normalize request, ignoring unsupported parameters."""
|
|
297
|
+
unsupported: List[str] = []
|
|
298
|
+
if request.temperature is not None:
|
|
299
|
+
unsupported.append("temperature")
|
|
300
|
+
if request.max_tokens is not None:
|
|
301
|
+
unsupported.append("max_tokens")
|
|
302
|
+
if unsupported:
|
|
303
|
+
# Log warning but continue - ignore unsupported parameters
|
|
304
|
+
logger.warning(
|
|
305
|
+
f"Codex CLI ignoring unsupported parameters: {', '.join(unsupported)}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _build_prompt(self, request: ProviderRequest) -> str:
|
|
309
|
+
"""
|
|
310
|
+
Build prompt with sandbox security warning injected.
|
|
311
|
+
|
|
312
|
+
Combines user system prompt + SANDBOX_WARNING + user prompt to ensure
|
|
313
|
+
the AI is aware of the read-only sandbox restrictions.
|
|
314
|
+
"""
|
|
315
|
+
parts = []
|
|
316
|
+
|
|
317
|
+
# Add user system prompt if provided
|
|
318
|
+
if request.system_prompt:
|
|
319
|
+
parts.append(request.system_prompt.strip())
|
|
320
|
+
|
|
321
|
+
# Add sandbox warning (always)
|
|
322
|
+
parts.append(SANDBOX_WARNING.strip())
|
|
323
|
+
|
|
324
|
+
# Add user prompt
|
|
325
|
+
parts.append(request.prompt)
|
|
326
|
+
|
|
327
|
+
return "\n\n".join(parts)
|
|
328
|
+
|
|
329
|
+
def _normalize_attachment_paths(self, request: ProviderRequest) -> List[str]:
|
|
330
|
+
attachments = []
|
|
331
|
+
for entry in request.attachments:
|
|
332
|
+
if isinstance(entry, str) and entry.strip():
|
|
333
|
+
attachments.append(entry.strip())
|
|
334
|
+
return attachments
|
|
335
|
+
|
|
336
|
+
def _build_command(self, model: str, attachments: List[str]) -> List[str]:
|
|
337
|
+
# Note: codex CLI requires --json flag for JSONL output (non-interactive mode)
|
|
338
|
+
# --skip-git-repo-check allows running outside trusted git directories
|
|
339
|
+
# Using "-" to read prompt from stdin (avoids CLI arg length limits for long prompts)
|
|
340
|
+
command = [self._binary, "exec", "--sandbox", "read-only", "--skip-git-repo-check", "--json"]
|
|
341
|
+
if model:
|
|
342
|
+
command.extend(["-m", model])
|
|
343
|
+
for path in attachments:
|
|
344
|
+
command.extend(["--image", path])
|
|
345
|
+
command.append("-") # Read prompt from stdin
|
|
346
|
+
return command
|
|
347
|
+
|
|
348
|
+
def _run(
|
|
349
|
+
self, command: Sequence[str], timeout: Optional[float], input_data: Optional[str] = None
|
|
350
|
+
) -> subprocess.CompletedProcess[str]:
|
|
351
|
+
try:
|
|
352
|
+
return self._runner(
|
|
353
|
+
command, timeout=int(timeout) if timeout else None, env=self._env, input_data=input_data
|
|
354
|
+
)
|
|
355
|
+
except FileNotFoundError as exc:
|
|
356
|
+
raise ProviderUnavailableError(
|
|
357
|
+
f"Codex CLI '{self._binary}' is not available on PATH.",
|
|
358
|
+
provider=self.metadata.provider_id,
|
|
359
|
+
) from exc
|
|
360
|
+
except subprocess.TimeoutExpired as exc:
|
|
361
|
+
raise ProviderTimeoutError(
|
|
362
|
+
f"Command timed out after {exc.timeout} seconds",
|
|
363
|
+
provider=self.metadata.provider_id,
|
|
364
|
+
) from exc
|
|
365
|
+
|
|
366
|
+
def _flatten_text(self, payload: Any) -> str:
|
|
367
|
+
if isinstance(payload, str):
|
|
368
|
+
return payload
|
|
369
|
+
if isinstance(payload, dict):
|
|
370
|
+
pieces: List[str] = []
|
|
371
|
+
for key in ("text", "content", "value"):
|
|
372
|
+
value = payload.get(key)
|
|
373
|
+
if value:
|
|
374
|
+
pieces.append(self._flatten_text(value))
|
|
375
|
+
if "parts" in payload and isinstance(payload["parts"], list):
|
|
376
|
+
pieces.extend(self._flatten_text(part) for part in payload["parts"])
|
|
377
|
+
if "messages" in payload and isinstance(payload["messages"], list):
|
|
378
|
+
pieces.extend(self._flatten_text(message) for message in payload["messages"])
|
|
379
|
+
return "".join(pieces)
|
|
380
|
+
if isinstance(payload, list):
|
|
381
|
+
return "".join(self._flatten_text(item) for item in payload)
|
|
382
|
+
return ""
|
|
383
|
+
|
|
384
|
+
def _extract_agent_text(self, payload: Dict[str, Any]) -> str:
|
|
385
|
+
# Check if this is an item with type="agent_message" or type="reasoning"
|
|
386
|
+
item_type = payload.get("type")
|
|
387
|
+
if item_type in ("agent_message", "reasoning"):
|
|
388
|
+
text = self._flatten_text(payload)
|
|
389
|
+
if text:
|
|
390
|
+
return text
|
|
391
|
+
|
|
392
|
+
# Check for specific message keys
|
|
393
|
+
for key in ("agent_message", "message", "delta", "content"):
|
|
394
|
+
if key in payload:
|
|
395
|
+
text = self._flatten_text(payload[key])
|
|
396
|
+
if text:
|
|
397
|
+
return text
|
|
398
|
+
|
|
399
|
+
# Recurse into nested item
|
|
400
|
+
item = payload.get("item")
|
|
401
|
+
if isinstance(item, dict):
|
|
402
|
+
return self._extract_agent_text(item)
|
|
403
|
+
return ""
|
|
404
|
+
|
|
405
|
+
def _token_usage_from_payload(self, payload: Dict[str, Any]) -> TokenUsage:
|
|
406
|
+
usage = payload.get("usage") or payload.get("token_usage") or {}
|
|
407
|
+
cached = usage.get("cached_input_tokens") or usage.get("cached_tokens") or 0
|
|
408
|
+
return TokenUsage(
|
|
409
|
+
input_tokens=int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0),
|
|
410
|
+
output_tokens=int(usage.get("output_tokens") or usage.get("completion_tokens") or 0),
|
|
411
|
+
cached_input_tokens=int(cached),
|
|
412
|
+
total_tokens=int(usage.get("total_tokens") or 0),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _process_events(
|
|
416
|
+
self,
|
|
417
|
+
stdout: str,
|
|
418
|
+
*,
|
|
419
|
+
stream: bool,
|
|
420
|
+
) -> Tuple[str, TokenUsage, Dict[str, Any], Optional[str]]:
|
|
421
|
+
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
422
|
+
if not lines:
|
|
423
|
+
raise ProviderExecutionError(
|
|
424
|
+
"Codex CLI returned empty output.",
|
|
425
|
+
provider=self.metadata.provider_id,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
events: List[Dict[str, Any]] = []
|
|
429
|
+
final_content = ""
|
|
430
|
+
usage = TokenUsage()
|
|
431
|
+
thread_id: Optional[str] = None
|
|
432
|
+
reported_model: Optional[str] = None
|
|
433
|
+
stream_index = 0
|
|
434
|
+
streamed_chunks: List[str] = []
|
|
435
|
+
|
|
436
|
+
for line in lines:
|
|
437
|
+
try:
|
|
438
|
+
event = json.loads(line)
|
|
439
|
+
except json.JSONDecodeError as exc:
|
|
440
|
+
raise ProviderExecutionError(
|
|
441
|
+
f"Codex CLI emitted invalid JSON: {exc}",
|
|
442
|
+
provider=self.metadata.provider_id,
|
|
443
|
+
) from exc
|
|
444
|
+
|
|
445
|
+
events.append(event)
|
|
446
|
+
event_type = str(event.get("type") or event.get("event") or "").lower()
|
|
447
|
+
|
|
448
|
+
if event_type == "thread.started":
|
|
449
|
+
thread_id = (
|
|
450
|
+
event.get("thread_id")
|
|
451
|
+
or (event.get("thread") or {}).get("id")
|
|
452
|
+
or event.get("id")
|
|
453
|
+
)
|
|
454
|
+
elif event_type in {"item.delta", "response.delta"}:
|
|
455
|
+
delta_text = self._extract_agent_text(event)
|
|
456
|
+
if delta_text:
|
|
457
|
+
streamed_chunks.append(delta_text)
|
|
458
|
+
if stream:
|
|
459
|
+
self._emit_stream_chunk(StreamChunk(content=delta_text, index=stream_index))
|
|
460
|
+
stream_index += 1
|
|
461
|
+
elif event_type in {"item.completed", "response.completed"}:
|
|
462
|
+
completed_text = self._extract_agent_text(event)
|
|
463
|
+
if completed_text:
|
|
464
|
+
final_content = completed_text
|
|
465
|
+
elif event_type in {"turn.completed", "usage"}:
|
|
466
|
+
usage = self._token_usage_from_payload(event)
|
|
467
|
+
if reported_model is None:
|
|
468
|
+
reported_model = (
|
|
469
|
+
event.get("model")
|
|
470
|
+
or (event.get("item") or {}).get("model")
|
|
471
|
+
or (event.get("agent_message") or {}).get("model")
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
if not final_content:
|
|
475
|
+
if streamed_chunks:
|
|
476
|
+
final_content = "".join(streamed_chunks)
|
|
477
|
+
else:
|
|
478
|
+
raise ProviderExecutionError(
|
|
479
|
+
"Codex CLI did not emit a completion event.",
|
|
480
|
+
provider=self.metadata.provider_id,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
metadata: Dict[str, Any] = {}
|
|
484
|
+
if thread_id:
|
|
485
|
+
metadata["thread_id"] = thread_id
|
|
486
|
+
metadata["events"] = events
|
|
487
|
+
|
|
488
|
+
return final_content, usage, metadata, reported_model
|
|
489
|
+
|
|
490
|
+
def _extract_error_from_jsonl(self, stdout: str) -> Optional[str]:
|
|
491
|
+
"""
|
|
492
|
+
Extract error message from Codex JSONL output.
|
|
493
|
+
|
|
494
|
+
Codex CLI outputs errors as JSONL events to stdout, not stderr.
|
|
495
|
+
Look for {"type":"error"} or {"type":"turn.failed"} events.
|
|
496
|
+
"""
|
|
497
|
+
if not stdout:
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
errors: List[str] = []
|
|
501
|
+
for line in stdout.strip().splitlines():
|
|
502
|
+
if not line.strip():
|
|
503
|
+
continue
|
|
504
|
+
try:
|
|
505
|
+
event = json.loads(line)
|
|
506
|
+
except json.JSONDecodeError:
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
event_type = str(event.get("type", "")).lower()
|
|
510
|
+
|
|
511
|
+
# Extract from {"type":"error","message":"..."}
|
|
512
|
+
if event_type == "error":
|
|
513
|
+
msg = event.get("message", "")
|
|
514
|
+
# Skip reconnection messages, get the final error
|
|
515
|
+
if msg and not msg.startswith("Reconnecting"):
|
|
516
|
+
errors.append(msg)
|
|
517
|
+
|
|
518
|
+
# Extract from {"type":"turn.failed","error":{"message":"..."}}
|
|
519
|
+
elif event_type == "turn.failed":
|
|
520
|
+
error_obj = event.get("error", {})
|
|
521
|
+
if isinstance(error_obj, dict):
|
|
522
|
+
msg = error_obj.get("message", "")
|
|
523
|
+
if msg:
|
|
524
|
+
errors.append(msg)
|
|
525
|
+
|
|
526
|
+
# Return the last (most specific) error, or join if multiple
|
|
527
|
+
if errors:
|
|
528
|
+
# Deduplicate while preserving order
|
|
529
|
+
seen = set()
|
|
530
|
+
unique_errors = []
|
|
531
|
+
for e in errors:
|
|
532
|
+
if e not in seen:
|
|
533
|
+
seen.add(e)
|
|
534
|
+
unique_errors.append(e)
|
|
535
|
+
return "; ".join(unique_errors)
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
539
|
+
self._validate_request(request)
|
|
540
|
+
# Resolve model: request.model takes precedence, then metadata, then instance default
|
|
541
|
+
model = (
|
|
542
|
+
request.model
|
|
543
|
+
or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
|
|
544
|
+
or self._model
|
|
545
|
+
)
|
|
546
|
+
prompt = self._build_prompt(request)
|
|
547
|
+
attachments = self._normalize_attachment_paths(request)
|
|
548
|
+
command = self._build_command(model, attachments)
|
|
549
|
+
timeout = request.timeout or self._timeout
|
|
550
|
+
completed = self._run(command, timeout=timeout, input_data=prompt)
|
|
551
|
+
|
|
552
|
+
if completed.returncode != 0:
|
|
553
|
+
stderr = (completed.stderr or "").strip()
|
|
554
|
+
logger.debug(f"Codex CLI stderr: {stderr or 'no stderr'}")
|
|
555
|
+
|
|
556
|
+
# Extract error message from JSONL stdout (Codex outputs errors there, not stderr)
|
|
557
|
+
jsonl_error = self._extract_error_from_jsonl(completed.stdout)
|
|
558
|
+
|
|
559
|
+
error_msg = f"Codex CLI exited with code {completed.returncode}"
|
|
560
|
+
if jsonl_error:
|
|
561
|
+
error_msg += f": {jsonl_error[:500]}"
|
|
562
|
+
elif stderr:
|
|
563
|
+
error_msg += f": {stderr[:500]}"
|
|
564
|
+
raise ProviderExecutionError(
|
|
565
|
+
error_msg,
|
|
566
|
+
provider=self.metadata.provider_id,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
content, usage, metadata, reported_model = self._process_events(
|
|
570
|
+
completed.stdout,
|
|
571
|
+
stream=request.stream,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
return ProviderResult(
|
|
575
|
+
content=content,
|
|
576
|
+
provider_id=self.metadata.provider_id,
|
|
577
|
+
model_used=f"{self.metadata.provider_id}:{reported_model or model}",
|
|
578
|
+
status=ProviderStatus.SUCCESS,
|
|
579
|
+
tokens=usage,
|
|
580
|
+
stderr=(completed.stderr or "").strip() or None,
|
|
581
|
+
raw_payload=metadata,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def is_codex_available() -> bool:
|
|
586
|
+
"""Codex CLI availability check."""
|
|
587
|
+
return detect_provider_availability("codex")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def create_provider(
|
|
591
|
+
*,
|
|
592
|
+
hooks: ProviderHooks,
|
|
593
|
+
model: Optional[str] = None,
|
|
594
|
+
dependencies: Optional[Dict[str, object]] = None,
|
|
595
|
+
overrides: Optional[Dict[str, object]] = None,
|
|
596
|
+
) -> CodexProvider:
|
|
597
|
+
"""
|
|
598
|
+
Factory used by the provider registry.
|
|
599
|
+
|
|
600
|
+
dependencies/overrides allow callers (or tests) to inject runner/env/binary.
|
|
601
|
+
"""
|
|
602
|
+
dependencies = dependencies or {}
|
|
603
|
+
overrides = overrides or {}
|
|
604
|
+
runner = dependencies.get("runner")
|
|
605
|
+
env = dependencies.get("env")
|
|
606
|
+
binary = overrides.get("binary") or dependencies.get("binary")
|
|
607
|
+
timeout = overrides.get("timeout")
|
|
608
|
+
selected_model = overrides.get("model") if overrides.get("model") else model
|
|
609
|
+
|
|
610
|
+
return CodexProvider(
|
|
611
|
+
metadata=CODEX_METADATA,
|
|
612
|
+
hooks=hooks,
|
|
613
|
+
model=selected_model, # type: ignore[arg-type]
|
|
614
|
+
binary=binary, # type: ignore[arg-type]
|
|
615
|
+
runner=runner if runner is not None else None, # type: ignore[arg-type]
|
|
616
|
+
env=env if env is not None else None, # type: ignore[arg-type]
|
|
617
|
+
timeout=timeout if timeout is not None else None, # type: ignore[arg-type]
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
register_provider(
|
|
622
|
+
"codex",
|
|
623
|
+
factory=create_provider,
|
|
624
|
+
metadata=CODEX_METADATA,
|
|
625
|
+
availability_check=is_codex_available,
|
|
626
|
+
description="OpenAI Codex CLI adapter with native OS-level read-only sandboxing",
|
|
627
|
+
tags=("cli", "text", "function_calling", "read-only", "sandboxed"),
|
|
628
|
+
replace=True,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
__all__ = [
|
|
633
|
+
"CodexProvider",
|
|
634
|
+
"create_provider",
|
|
635
|
+
"is_codex_available",
|
|
636
|
+
"CODEX_METADATA",
|
|
637
|
+
]
|