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.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +146 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. 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
+ ]