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