@pushpalsdev/cli 1.0.18 → 1.0.19

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 (106) hide show
  1. package/dist/pushpals-cli.js +277 -12
  2. package/package.json +1 -1
  3. package/runtime/sandbox/apps/workerpals/.python-version +1 -0
  4. package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
  5. package/runtime/sandbox/apps/workerpals/package.json +25 -0
  6. package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
  7. package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +111 -0
  8. package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
  9. package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
  10. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
  11. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
  12. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
  13. package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
  14. package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
  15. package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
  16. package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
  17. package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
  18. package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
  19. package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
  20. package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
  21. package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
  22. package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
  23. package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
  24. package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
  25. package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
  26. package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
  27. package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
  28. package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
  29. package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
  30. package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
  31. package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
  32. package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
  33. package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
  34. package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
  35. package/runtime/sandbox/bun.lock +2591 -0
  36. package/runtime/sandbox/configs/backend.toml +79 -0
  37. package/runtime/sandbox/configs/default.toml +260 -0
  38. package/runtime/sandbox/configs/dev.toml +2 -0
  39. package/runtime/sandbox/configs/local.example.toml +129 -0
  40. package/runtime/sandbox/package.json +65 -0
  41. package/runtime/sandbox/packages/protocol/README.md +168 -0
  42. package/runtime/sandbox/packages/protocol/package.json +37 -0
  43. package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
  44. package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
  45. package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
  46. package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
  47. package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
  48. package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
  49. package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
  50. package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
  51. package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
  52. package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
  53. package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
  54. package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
  55. package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
  56. package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
  57. package/runtime/sandbox/packages/shared/package.json +19 -0
  58. package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
  59. package/runtime/sandbox/packages/shared/src/client_preflight.ts +297 -0
  60. package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
  61. package/runtime/sandbox/packages/shared/src/config.ts +2201 -0
  62. package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
  63. package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
  64. package/runtime/sandbox/packages/shared/src/index.ts +100 -0
  65. package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
  66. package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +329 -0
  67. package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
  68. package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
  69. package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
  70. package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
  71. package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
  72. package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
  73. package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
  74. package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
  75. package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
  76. package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
  77. package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
  78. package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
  79. package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
  80. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
  81. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
  82. package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
  83. package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
  84. package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
  85. package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
  86. package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
  87. package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
  88. package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
  89. package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
  90. package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
  91. package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
  92. package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
  93. package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
  94. package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
  95. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
  96. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
  97. package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
  98. package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
  99. package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
  100. package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
  101. package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
  102. package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
  103. package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
  104. package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
  105. package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
  106. package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
@@ -0,0 +1,1259 @@
1
+ #!/usr/bin/env python3
2
+ """PushPals OpenAI Codex backend wrapper.
3
+
4
+ Runs `codex exec` in non-interactive mode and emits one structured result line
5
+ that the TypeScript host parses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from shutil import which
14
+ import shlex
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ import traceback
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ _SHARED_DIR = Path(__file__).resolve().parents[1] / "shared"
27
+ if str(_SHARED_DIR) not in sys.path:
28
+ sys.path.insert(0, str(_SHARED_DIR))
29
+
30
+ from executor_base import (
31
+ Logger,
32
+ SettingsResolver,
33
+ build_settings_resolver,
34
+ emit,
35
+ log_git_status,
36
+ looks_local_base_url,
37
+ parse_task_execute_payload,
38
+ resolve_llm_config,
39
+ summarize_git_changes,
40
+ to_int,
41
+ to_single_line,
42
+ )
43
+
44
+ LOG_PREFIX = "[OpenAICodexExecutor]"
45
+ DEFAULT_CODEX_MODEL = "gpt-5-codex"
46
+ _ACTIVE_CHILD: Optional[subprocess.Popen[str]] = None
47
+ _INTERRUPTED_SIGNAL: Optional[int] = None
48
+ log = Logger(LOG_PREFIX)
49
+
50
+ _PROMPT_TEMPLATE_CACHE: Dict[str, str] = {}
51
+ _PROMPT_TOKEN_REGEX = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
52
+ _TASK_SYSTEM_PROMPT_PATH = "workerpals/openai_codex_task_execute_system_prompt.md"
53
+ _DEFAULT_TASK_SYSTEM_PROMPT_PATH = "workerpals/openai_codex_default_system_prompt.md"
54
+ _MANDATORY_RUNTIME_POLICY_APPENDIX_PATH = "workerpals/openai_codex_runtime_policy_appendix.md"
55
+ _INSTRUCTION_WRAPPER_PROMPT_PATH = "workerpals/openai_codex_instruction_wrapper.md"
56
+ _SUPPLEMENTAL_GUIDANCE_SECTION_PATH = "workerpals/openai_codex_supplemental_guidance_section.md"
57
+ _CODEX_WORKAROUND_PATTERNS = (
58
+ re.compile(
59
+ r"\bcodex cli\b.{0,120}\b(isn't|is not|not)\b.{0,120}\bavailable\b.{0,120}\b(so|therefore|instead|fallback|workaround|without|using)\b",
60
+ re.IGNORECASE,
61
+ ),
62
+ re.compile(r"\bwithout requiring\b.{0,120}\bcodex\b", re.IGNORECASE),
63
+ re.compile(r"\bavoid(?:ing)?\b.{0,120}\bcodex\b.{0,120}\bcall", re.IGNORECASE),
64
+ re.compile(r"\b(fell back|fallback|worked around|workaround|bypass(?:ed)?|switched to)\b.{0,120}\bcodex\b", re.IGNORECASE),
65
+ )
66
+ _CODEX_WORKAROUND_NEGATION_HINTS = (
67
+ "do not",
68
+ "don't",
69
+ "never",
70
+ "must not",
71
+ "fail loudly",
72
+ "hard-fail",
73
+ "hard fail",
74
+ "explicit failure",
75
+ "codex cli is required infrastructure",
76
+ )
77
+
78
+ _VALID_APPROVAL_POLICIES = {"untrusted", "on-failure", "on-request", "never"}
79
+ _VALID_SANDBOX_POLICIES = {"read-only", "workspace-write", "danger-full-access"}
80
+ _VALID_COLORS = {"always", "never", "auto"}
81
+ _VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"}
82
+ _VALID_REASONING_EFFORTS = {"low", "medium", "high"}
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class OpenAICodexRuntimeConfig:
87
+ codex_bin_json: str
88
+ codex_bin: str
89
+ auth_mode: str
90
+ base_url_override: str
91
+ timeout_seconds_override: int
92
+ timeout_ms_top_level: int
93
+ timeout_ms_llm_codex: int
94
+ timeout_ms_backend: int
95
+ progress_log_interval_s: int
96
+ reasoning_effort: str
97
+ approval_policy: str
98
+ sandbox: str
99
+ color: str
100
+ json_output: bool
101
+
102
+ @classmethod
103
+ def from_sources(cls, settings: Optional[SettingsResolver] = None) -> "OpenAICodexRuntimeConfig":
104
+ cfg = settings or build_settings_resolver()
105
+ return cls(
106
+ codex_bin_json=cfg.get_str(
107
+ env_names=("PUSHPALS_OPENAI_CODEX_BIN_JSON",),
108
+ config_paths=("workerpals.llm.codex_bin_json", "workerpals.openai_codex.bin_json"),
109
+ default="",
110
+ ),
111
+ codex_bin=cfg.get_str(
112
+ env_names=("PUSHPALS_OPENAI_CODEX_BIN",),
113
+ config_paths=("workerpals.llm.codex_bin", "workerpals.openai_codex.bin"),
114
+ default="",
115
+ ),
116
+ auth_mode=cfg.get_str(
117
+ env_names=("PUSHPALS_OPENAI_CODEX_AUTH_MODE",),
118
+ config_paths=("workerpals.llm.codex_auth_mode", "workerpals.openai_codex.auth_mode"),
119
+ default="auto",
120
+ ),
121
+ base_url_override=cfg.get_str(
122
+ env_names=("PUSHPALS_OPENAI_CODEX_BASE_URL",),
123
+ config_paths=("workerpals.llm.codex_base_url", "workerpals.openai_codex.base_url"),
124
+ default="",
125
+ ),
126
+ timeout_seconds_override=cfg.get_int(
127
+ env_names=("WORKERPALS_OPENAI_CODEX_TIMEOUT_S",),
128
+ config_paths=("workerpals.openai_codex.timeout_s",),
129
+ default=0,
130
+ ),
131
+ timeout_ms_top_level=cfg.get_int(
132
+ env_names=("WORKERPALS_OPENAI_CODEX_TIMEOUT_MS",),
133
+ config_paths=("workerpals.openai_codex_timeout_ms",),
134
+ default=0,
135
+ ),
136
+ timeout_ms_llm_codex=cfg.get_int(
137
+ env_names=("WORKERPALS_LLM_CODEX_TIMEOUT_MS",),
138
+ config_paths=("workerpals.llm.codex_timeout_ms",),
139
+ default=0,
140
+ ),
141
+ timeout_ms_backend=cfg.get_int(
142
+ env_names=("WORKERPALS_OPENAI_CODEX_BACKEND_TIMEOUT_MS",),
143
+ config_paths=("workerpals.openai_codex.timeout_ms",),
144
+ default=0,
145
+ ),
146
+ progress_log_interval_s=cfg.get_int(
147
+ env_names=("WORKERPALS_OPENAI_CODEX_PROGRESS_LOG_INTERVAL_S",),
148
+ config_paths=("workerpals.openai_codex.progress_log_interval_s",),
149
+ default=30,
150
+ ),
151
+ reasoning_effort=cfg.get_str(
152
+ env_names=("WORKERPALS_LLM_REASONING_EFFORT", "WORKERPALS_OPENAI_CODEX_REASONING_EFFORT"),
153
+ config_paths=("workerpals.llm.reasoning_effort", "workerpals.openai_codex.reasoning_effort"),
154
+ default="high",
155
+ ),
156
+ approval_policy=cfg.get_str(
157
+ env_names=("WORKERPALS_OPENAI_CODEX_APPROVAL_POLICY",),
158
+ config_paths=("workerpals.openai_codex.approval_policy",),
159
+ default="never",
160
+ ),
161
+ sandbox=cfg.get_str(
162
+ env_names=("WORKERPALS_OPENAI_CODEX_SANDBOX",),
163
+ config_paths=("workerpals.openai_codex.sandbox",),
164
+ default="workspace-write",
165
+ ),
166
+ color=cfg.get_str(
167
+ env_names=("WORKERPALS_OPENAI_CODEX_COLOR",),
168
+ config_paths=("workerpals.openai_codex.color",),
169
+ default="never",
170
+ ),
171
+ json_output=cfg.get_bool(
172
+ env_names=("WORKERPALS_OPENAI_CODEX_JSON",),
173
+ config_paths=("workerpals.openai_codex.json",),
174
+ default=False,
175
+ ),
176
+ )
177
+
178
+
179
+ def shutil_which(binary: str) -> str:
180
+ return which(binary) or ""
181
+
182
+
183
+ def _truncate(text: str, max_chars: int = 4000) -> str:
184
+ value = str(text or "")
185
+ if len(value) <= max_chars:
186
+ return value
187
+ return value[: max(1, max_chars - 15)] + "\n...[truncated]"
188
+
189
+
190
+ def _repo_root_for_prompt_loading() -> Path:
191
+ current = Path(__file__).resolve()
192
+ for parent in current.parents:
193
+ if (parent / "prompts").is_dir():
194
+ return parent
195
+ # Fallback to historical layout depth if prompts/ cannot be discovered.
196
+ return current.parents[5]
197
+
198
+
199
+ def _resolve_prompt_file(relative_path: str) -> Path:
200
+ return _repo_root_for_prompt_loading() / "prompts" / relative_path
201
+
202
+
203
+ def _load_prompt_template(
204
+ relative_path: str, replacements: Optional[Dict[str, str]] = None
205
+ ) -> str:
206
+ prompt_path = _resolve_prompt_file(relative_path)
207
+ cache_key = str(prompt_path)
208
+ cached = _PROMPT_TEMPLATE_CACHE.get(cache_key)
209
+ if cached is not None:
210
+ template = cached
211
+ else:
212
+ try:
213
+ template = prompt_path.read_text(encoding="utf-8").strip()
214
+ except Exception:
215
+ template = ""
216
+ _PROMPT_TEMPLATE_CACHE[cache_key] = template
217
+ if not replacements:
218
+ return template
219
+
220
+ def _replace(match: re.Match[str]) -> str:
221
+ key = match.group(1)
222
+ value = replacements.get(key)
223
+ if value is None:
224
+ raise KeyError(f"Missing prompt replacement '{{{{{key}}}}}' for {prompt_path}")
225
+ return value
226
+
227
+ return _PROMPT_TOKEN_REGEX.sub(_replace, template)
228
+
229
+
230
+ def _to_positive_int(raw: str) -> Optional[int]:
231
+ try:
232
+ parsed = int(raw)
233
+ except Exception:
234
+ return None
235
+ return parsed if parsed > 0 else None
236
+
237
+
238
+ def _normalize_choice(
239
+ value: str,
240
+ valid: set[str],
241
+ default: str,
242
+ *,
243
+ env_name: str,
244
+ ) -> str:
245
+ normalized = value.strip().lower()
246
+ if normalized in valid:
247
+ return normalized
248
+ if normalized:
249
+ log.info(
250
+ f"Invalid {env_name}={value!r}; using default {default!r}. "
251
+ f"Allowed: {', '.join(sorted(valid))}."
252
+ )
253
+ return default
254
+
255
+
256
+ def _is_git_repo(repo: str) -> bool:
257
+ try:
258
+ proc = subprocess.run(
259
+ ["git", "rev-parse", "--is-inside-work-tree"],
260
+ cwd=repo,
261
+ capture_output=True,
262
+ text=True,
263
+ timeout=10,
264
+ check=False,
265
+ )
266
+ if proc.returncode != 0:
267
+ return False
268
+ return (proc.stdout or "").strip().lower() == "true"
269
+ except Exception:
270
+ return False
271
+
272
+
273
+ def _resolve_codex_command_prefix(config: OpenAICodexRuntimeConfig) -> List[str]:
274
+ override_json = config.codex_bin_json
275
+ if override_json:
276
+ try:
277
+ parsed = json.loads(override_json)
278
+ if isinstance(parsed, list):
279
+ parts = [str(p).strip() for p in parsed if str(p).strip()]
280
+ if parts:
281
+ return parts
282
+ except Exception:
283
+ log.info(
284
+ "Invalid PUSHPALS_OPENAI_CODEX_BIN_JSON; expected JSON array of command segments."
285
+ )
286
+
287
+ override = config.codex_bin
288
+ if override:
289
+ try:
290
+ parts = [p for p in shlex.split(override) if p.strip()]
291
+ except Exception:
292
+ log.info(
293
+ "Invalid PUSHPALS_OPENAI_CODEX_BIN value; expected a command string parseable by shlex."
294
+ )
295
+ return []
296
+ return parts
297
+
298
+ # Prefer bunx to avoid requiring a separate node runtime in the container.
299
+ if shutil_which("bunx"):
300
+ return ["bunx", "--yes", "@openai/codex"]
301
+ if shutil_which("codex"):
302
+ return ["codex"]
303
+ return []
304
+
305
+
306
+ def _resolve_communicate_timeout_seconds(config: OpenAICodexRuntimeConfig) -> Optional[int]:
307
+ explicit_s = _to_positive_int(str(config.timeout_seconds_override))
308
+ if explicit_s is not None:
309
+ return explicit_s
310
+ # Top-level execution budget (e.g. openai_codex_timeout_ms = 7200000 in [workerpals])
311
+ # takes precedence over the more granular LLM/CLI-level timeout settings.
312
+ top_level_ms = config.timeout_ms_top_level
313
+ if top_level_ms > 0:
314
+ return max(1, top_level_ms // 1000)
315
+ timeout_ms = config.timeout_ms_llm_codex
316
+ if timeout_ms <= 0:
317
+ timeout_ms = config.timeout_ms_backend
318
+ if timeout_ms <= 0:
319
+ return None
320
+ return max(1, timeout_ms // 1000)
321
+
322
+
323
+ def _resolve_reasoning_effort(config: OpenAICodexRuntimeConfig) -> str:
324
+ raw = config.reasoning_effort
325
+ normalized = str(raw).strip().lower()
326
+ if normalized in _VALID_REASONING_EFFORTS:
327
+ return normalized
328
+ log.info(
329
+ "Invalid workerpals.openai_codex.reasoning_effort="
330
+ f"{raw!r}; using default 'high'. Allowed: low, medium, high."
331
+ )
332
+ return "high"
333
+
334
+
335
+ def _resolve_progress_log_interval_seconds(config: OpenAICodexRuntimeConfig) -> int:
336
+ interval = to_int(config.progress_log_interval_s, 30)
337
+ # Avoid noisy logs (<30s) and stale logs (>120s).
338
+ return max(30, min(120, interval))
339
+
340
+
341
+ def _normalize_auth_mode(raw: str) -> str:
342
+ lowered = (raw or "").strip().lower()
343
+ aliases = {
344
+ "apikey": "api_key",
345
+ "api": "api_key",
346
+ "api-key": "api_key",
347
+ "chatgpt_login": "chatgpt",
348
+ "chatgpt-pro": "chatgpt",
349
+ "subscription": "chatgpt",
350
+ }
351
+ normalized = aliases.get(lowered, lowered)
352
+ if normalized in _VALID_AUTH_MODES:
353
+ return normalized
354
+ if lowered:
355
+ log.info(
356
+ f"Invalid PUSHPALS_OPENAI_CODEX_AUTH_MODE={raw!r}; using default 'auto'. "
357
+ f"Allowed: {', '.join(sorted(_VALID_AUTH_MODES))}."
358
+ )
359
+ return "auto"
360
+
361
+
362
+ def _run_codex_login_status(codex_cmd_prefix: List[str], repo: str, env: Dict[str, str]) -> Dict[str, Any]:
363
+ try:
364
+ proc = subprocess.run(
365
+ [*codex_cmd_prefix, "login", "status"],
366
+ cwd=repo,
367
+ env=env,
368
+ capture_output=True,
369
+ text=True,
370
+ encoding="utf-8",
371
+ errors="replace",
372
+ timeout=25,
373
+ check=False,
374
+ )
375
+ return {
376
+ "ok": proc.returncode == 0,
377
+ "exitCode": int(proc.returncode),
378
+ "stdout": proc.stdout or "",
379
+ "stderr": proc.stderr or "",
380
+ }
381
+ except Exception as exc:
382
+ return {
383
+ "ok": False,
384
+ "exitCode": 1,
385
+ "stdout": "",
386
+ "stderr": f"Failed to run `codex login status`: {exc}",
387
+ }
388
+
389
+
390
+ def _terminate_active_child() -> None:
391
+ global _ACTIVE_CHILD
392
+ proc = _ACTIVE_CHILD
393
+ if proc is None or proc.poll() is not None:
394
+ return
395
+ try:
396
+ proc.terminate()
397
+ except Exception:
398
+ pass
399
+ try:
400
+ proc.wait(timeout=3)
401
+ except Exception:
402
+ try:
403
+ proc.kill()
404
+ except Exception:
405
+ pass
406
+
407
+
408
+ def _truncate_inline(text: str, max_chars: int = 180) -> str:
409
+ value = " ".join(str(text or "").split())
410
+ if len(value) <= max_chars:
411
+ return value
412
+ return value[: max(1, max_chars - 3)] + "..."
413
+
414
+
415
+ def _contains_reasoning_marker(value: str) -> bool:
416
+ lowered = str(value or "").strip().lower()
417
+ if not lowered:
418
+ return False
419
+ return "reasoning" in lowered or "thinking" in lowered
420
+
421
+
422
+ def _event_contains_reasoning(value: Any) -> bool:
423
+ max_nodes = 256
424
+ visited = 0
425
+ stack: List[Any] = [value]
426
+ while stack and visited < max_nodes:
427
+ current = stack.pop()
428
+ visited += 1
429
+ if isinstance(current, str):
430
+ if _contains_reasoning_marker(current):
431
+ return True
432
+ continue
433
+ if isinstance(current, list):
434
+ for item in reversed(current[:80]):
435
+ if isinstance(item, (dict, list, str)):
436
+ stack.append(item)
437
+ continue
438
+ if not isinstance(current, dict):
439
+ continue
440
+
441
+ for raw_key, nested in current.items():
442
+ key = str(raw_key or "")
443
+ key_lower = key.lower()
444
+ if _contains_reasoning_marker(key_lower):
445
+ return True
446
+ if key_lower in ("type", "kind", "event", "item_type", "role", "channel"):
447
+ if isinstance(nested, str) and _contains_reasoning_marker(nested):
448
+ return True
449
+ if isinstance(nested, (dict, list, str)):
450
+ stack.append(nested)
451
+
452
+ return False
453
+
454
+
455
+ def _collect_text_fragments(value: Any, out: List[str]) -> None:
456
+ if isinstance(value, str):
457
+ text = _truncate_inline(value, 220)
458
+ if text:
459
+ out.append(text)
460
+ return
461
+ if isinstance(value, list):
462
+ for item in value:
463
+ _collect_text_fragments(item, out)
464
+ return
465
+ if isinstance(value, dict):
466
+ matched_key = False
467
+ for raw_key, nested in value.items():
468
+ key = str(raw_key or "").lower()
469
+ if key.endswith("_text") or key.endswith("_message"):
470
+ matched_key = True
471
+ _collect_text_fragments(nested, out)
472
+ continue
473
+ if (
474
+ key in ("text", "content", "summary", "message", "error", "reason", "delta", "output", "item")
475
+ or _contains_reasoning_marker(key)
476
+ ):
477
+ matched_key = True
478
+ _collect_text_fragments(nested, out)
479
+ if not matched_key:
480
+ # Fallback: recurse into nested containers so unknown payload shapes still surface text.
481
+ for nested in value.values():
482
+ if isinstance(nested, (dict, list)):
483
+ _collect_text_fragments(nested, out)
484
+ return
485
+
486
+
487
+ def _summarize_json_event(obj: Dict[str, Any]) -> str:
488
+ event_type = str(obj.get("type") or obj.get("event") or obj.get("kind") or "event").strip()
489
+ if not event_type:
490
+ event_type = "event"
491
+ # Skip noisy streaming deltas unless they contain meaningful text fragments.
492
+ delta_like = event_type.endswith(".delta") or event_type.endswith("_delta")
493
+ # Reasoning/thinking events are always surfaced — they show the model's reasoning process.
494
+ reasoning_like = _contains_reasoning_marker(event_type) or _event_contains_reasoning(obj)
495
+
496
+ tool_name = ""
497
+ for key in ("tool_name", "tool", "name"):
498
+ raw = obj.get(key)
499
+ if isinstance(raw, str) and raw.strip():
500
+ tool_name = raw.strip()
501
+ break
502
+ if isinstance(raw, dict):
503
+ nested = raw.get("name")
504
+ if isinstance(nested, str) and nested.strip():
505
+ tool_name = nested.strip()
506
+ break
507
+ # For Codex CLI item.* events, the tool/function name is nested under obj["item"]["name"].
508
+ if not tool_name and isinstance(obj.get("item"), dict):
509
+ nested = obj["item"].get("name")
510
+ if isinstance(nested, str) and nested.strip():
511
+ tool_name = nested.strip()
512
+
513
+ fragments: List[str] = []
514
+ # "item" covers Codex CLI's item.started/updated/completed events where reasoning and
515
+ # tool call content is nested under the item object.
516
+ # "output" covers turn.completed and similar events that carry output arrays.
517
+ # "delta" covers reasoning delta events (response.reasoning_summary_text.delta).
518
+ extract_keys = ["message", "text", "summary", "content", "output_text", "error", "item", "output", "delta"]
519
+ for key in extract_keys:
520
+ if key in obj:
521
+ _collect_text_fragments(obj.get(key), fragments)
522
+ deduped: List[str] = []
523
+ seen: set[str] = set()
524
+ for frag in fragments:
525
+ if frag in seen:
526
+ continue
527
+ seen.add(frag)
528
+ deduped.append(frag)
529
+ text_part = deduped[0] if deduped else ""
530
+
531
+ # Suppress noisy deltas, but always surface reasoning events even if text is empty.
532
+ if delta_like and not text_part and not reasoning_like:
533
+ return ""
534
+
535
+ parts = [event_type]
536
+ if tool_name:
537
+ parts.append(f"tool={tool_name}")
538
+ if text_part:
539
+ parts.append(text_part)
540
+ elif reasoning_like:
541
+ parts.append("reasoning update")
542
+ return " | ".join(parts)
543
+
544
+
545
+ def _format_codex_trace_excerpt(trace: Dict[str, Any], max_items: int = 20) -> str:
546
+ summaries = trace.get("summaries")
547
+ if isinstance(summaries, list):
548
+ items = [str(item).strip() for item in summaries if str(item).strip()]
549
+ if items:
550
+ shown = items[:max_items]
551
+ lines = [f"- {item}" for item in shown]
552
+ omitted = len(items) - len(shown)
553
+ if omitted > 0:
554
+ lines.append(f"- ... ({omitted} more event(s) omitted)")
555
+ return "Codex event trace:\n" + "\n".join(lines)
556
+
557
+ event_counts = trace.get("event_type_counts")
558
+ if isinstance(event_counts, dict):
559
+ pairs = [
560
+ (str(key).strip() or "event", to_int(value, 0))
561
+ for key, value in event_counts.items()
562
+ if to_int(value, 0) > 0
563
+ ]
564
+ if pairs:
565
+ pairs.sort(key=lambda item: item[1], reverse=True)
566
+ listed = ", ".join(f"{name}={count}" for name, count in pairs[:8])
567
+ return f"Codex event types: {listed}"
568
+
569
+ return ""
570
+
571
+
572
+ def _empty_codex_trace() -> Dict[str, Any]:
573
+ return {
574
+ "line_count": 0,
575
+ "valid_json": 0,
576
+ "invalid_json": 0,
577
+ "summaries": [],
578
+ "event_type_counts": {},
579
+ "live_logged": 0,
580
+ "live_omitted": 0,
581
+ "raw_logged": 0,
582
+ "raw_omitted": 0,
583
+ "reasoning_events": 0,
584
+ }
585
+
586
+
587
+ def _record_live_codex_stdout_line(line: str, use_json: bool, trace: Dict[str, Any]) -> None:
588
+ stripped = line.strip()
589
+ if not stripped:
590
+ return
591
+
592
+ trace["line_count"] = to_int(trace.get("line_count"), 0) + 1
593
+ summaries = trace.setdefault("summaries", [])
594
+ event_type_counts = trace.setdefault("event_type_counts", {})
595
+ max_recorded_summaries = 500
596
+ max_live_logged = 300
597
+ max_raw_logged = 5
598
+
599
+ if use_json:
600
+ try:
601
+ parsed = json.loads(stripped)
602
+ trace["valid_json"] = to_int(trace.get("valid_json"), 0) + 1
603
+ except Exception:
604
+ trace["invalid_json"] = to_int(trace.get("invalid_json"), 0) + 1
605
+ raw_logged = to_int(trace.get("raw_logged"), 0)
606
+ if raw_logged < max_raw_logged:
607
+ log.info(f"[codex/raw] {_truncate_inline(stripped, 220)}")
608
+ trace["raw_logged"] = raw_logged + 1
609
+ else:
610
+ trace["raw_omitted"] = to_int(trace.get("raw_omitted"), 0) + 1
611
+ return
612
+
613
+ if isinstance(parsed, dict):
614
+ event_type = (
615
+ str(parsed.get("type") or parsed.get("event") or parsed.get("kind") or "event")
616
+ .strip()
617
+ or "event"
618
+ )
619
+ event_type_counts[event_type] = to_int(event_type_counts.get(event_type), 0) + 1
620
+ summary = _summarize_json_event(parsed)
621
+ # Reasoning can arrive under generic event types (for example item.updated).
622
+ priority = _event_contains_reasoning(parsed)
623
+ if priority:
624
+ trace["reasoning_events"] = to_int(trace.get("reasoning_events"), 0) + 1
625
+ if summary:
626
+ if len(summaries) < max_recorded_summaries:
627
+ summaries.append(summary)
628
+ live_logged = to_int(trace.get("live_logged"), 0)
629
+ if live_logged < max_live_logged or priority:
630
+ log.info(f"[codex] {summary}")
631
+ trace["live_logged"] = live_logged + 1
632
+ else:
633
+ trace["live_omitted"] = to_int(trace.get("live_omitted"), 0) + 1
634
+ return
635
+
636
+ summary = _truncate_inline(stripped, 220)
637
+ if summary:
638
+ if len(summaries) < max_recorded_summaries:
639
+ summaries.append(summary)
640
+ live_logged = to_int(trace.get("live_logged"), 0)
641
+ if live_logged < max_live_logged:
642
+ log.info(f"[codex] {summary}")
643
+ trace["live_logged"] = live_logged + 1
644
+ else:
645
+ trace["live_omitted"] = to_int(trace.get("live_omitted"), 0) + 1
646
+
647
+
648
+ def _finalize_codex_stdout_trace(trace: Dict[str, Any], use_json: bool) -> Dict[str, Any]:
649
+ line_count = to_int(trace.get("line_count"), 0)
650
+ valid_json = to_int(trace.get("valid_json"), 0)
651
+ invalid_json = to_int(trace.get("invalid_json"), 0)
652
+ summaries = trace.get("summaries")
653
+ if not isinstance(summaries, list):
654
+ summaries = []
655
+ else:
656
+ summaries = [str(item).strip() for item in summaries if str(item).strip()]
657
+ event_type_counts_raw = trace.get("event_type_counts")
658
+ event_type_counts: Dict[str, int] = {}
659
+ if isinstance(event_type_counts_raw, dict):
660
+ for key, value in event_type_counts_raw.items():
661
+ name = str(key).strip() or "event"
662
+ count = to_int(value, 0)
663
+ if count > 0:
664
+ event_type_counts[name] = count
665
+
666
+ if use_json:
667
+ log.info(
668
+ f"Codex JSON stream captured ({line_count} line(s), valid_json={valid_json}, invalid={invalid_json})."
669
+ )
670
+ else:
671
+ log.info(f"Codex stdout captured ({line_count} non-empty line(s)).")
672
+
673
+ live_omitted = to_int(trace.get("live_omitted"), 0)
674
+ if live_omitted > 0:
675
+ log.info(f"[codex] ... {live_omitted} additional event(s) omitted.")
676
+ raw_omitted = to_int(trace.get("raw_omitted"), 0)
677
+ if raw_omitted > 0:
678
+ log.info(f"[codex/raw] ... {raw_omitted} additional line(s) omitted.")
679
+ reasoning_events = to_int(trace.get("reasoning_events"), 0)
680
+ if reasoning_events > 0:
681
+ log.info(f"[codex] Reasoning-like event(s): {reasoning_events}")
682
+ elif use_json and valid_json > 0:
683
+ log.info("[codex] No reasoning-like events observed in this run.")
684
+
685
+ if not summaries and event_type_counts:
686
+ ranked = sorted(event_type_counts.items(), key=lambda item: item[1], reverse=True)
687
+ top = ", ".join(f"{name}={count}" for name, count in ranked[:8])
688
+ log.info(f"[codex] Event types: {top}")
689
+
690
+ return {
691
+ "line_count": line_count,
692
+ "valid_json": valid_json,
693
+ "invalid_json": invalid_json,
694
+ "summaries": summaries,
695
+ "event_type_counts": event_type_counts,
696
+ "reasoning_events": reasoning_events,
697
+ }
698
+
699
+
700
+ def _log_stderr(stderr: str) -> None:
701
+ lines = [line.strip() for line in stderr.splitlines() if line.strip()]
702
+ if not lines:
703
+ return
704
+ max_lines = 20
705
+ for line in lines[:max_lines]:
706
+ log.info(f"[stderr] {line}")
707
+ if len(lines) > max_lines:
708
+ log.info(f"[stderr] ... {len(lines) - max_lines} additional line(s) omitted.")
709
+
710
+
711
+ def _safe_model_for_codex(raw_model: str, base_url: str) -> str:
712
+ model = str(raw_model or "").strip()
713
+ if not model:
714
+ return DEFAULT_CODEX_MODEL
715
+ if "/" not in model:
716
+ return model
717
+ provider, bare = model.split("/", 1)
718
+ provider = provider.strip().lower()
719
+ bare = bare.strip()
720
+ if provider == "openai" and bare:
721
+ return bare
722
+ if looks_local_base_url(base_url) and bare:
723
+ return bare
724
+ return DEFAULT_CODEX_MODEL
725
+
726
+
727
+ def _build_instruction(instruction: str, supplemental_guidance: List[str]) -> str:
728
+ system_prompt = (_load_prompt_template(_TASK_SYSTEM_PROMPT_PATH) or "").strip()
729
+ if not system_prompt:
730
+ system_prompt = (_load_prompt_template(_DEFAULT_TASK_SYSTEM_PROMPT_PATH) or "").strip()
731
+ if not system_prompt:
732
+ raise RuntimeError(
733
+ "Missing required OpenAI Codex system prompt template. "
734
+ f"Expected one of: {_TASK_SYSTEM_PROMPT_PATH}, {_DEFAULT_TASK_SYSTEM_PROMPT_PATH}"
735
+ )
736
+
737
+ runtime_policy_appendix = (
738
+ _load_prompt_template(_MANDATORY_RUNTIME_POLICY_APPENDIX_PATH) or ""
739
+ ).strip()
740
+ if not runtime_policy_appendix:
741
+ raise RuntimeError(
742
+ "Missing required OpenAI Codex runtime policy appendix template. "
743
+ f"Expected: {_MANDATORY_RUNTIME_POLICY_APPENDIX_PATH}"
744
+ )
745
+ if runtime_policy_appendix.lower() not in system_prompt.lower():
746
+ system_prompt = f"{system_prompt}\n\n{runtime_policy_appendix}".strip()
747
+
748
+ supplemental_section = ""
749
+ filtered_guidance = [str(item).strip() for item in supplemental_guidance if str(item).strip()]
750
+ if filtered_guidance:
751
+ supplemental_section_template = _load_prompt_template(_SUPPLEMENTAL_GUIDANCE_SECTION_PATH)
752
+ if not supplemental_section_template.strip():
753
+ raise RuntimeError(
754
+ "Missing required OpenAI Codex supplemental guidance section template. "
755
+ f"Expected: {_SUPPLEMENTAL_GUIDANCE_SECTION_PATH}"
756
+ )
757
+ supplemental_section = "\n\n" + _load_prompt_template(
758
+ _SUPPLEMENTAL_GUIDANCE_SECTION_PATH,
759
+ {"guidance_lines": "\n".join(filtered_guidance)},
760
+ ).strip()
761
+
762
+ wrapped = _load_prompt_template(
763
+ _INSTRUCTION_WRAPPER_PROMPT_PATH,
764
+ {
765
+ "system_prompt": system_prompt,
766
+ "instruction": instruction,
767
+ "supplemental_section": supplemental_section,
768
+ },
769
+ )
770
+ if not wrapped.strip():
771
+ raise RuntimeError(
772
+ "Missing required OpenAI Codex instruction wrapper template. "
773
+ f"Expected: {_INSTRUCTION_WRAPPER_PROMPT_PATH}"
774
+ )
775
+ return wrapped.strip()
776
+
777
+
778
+ def _detect_codex_workaround_signal(*texts: str) -> Optional[str]:
779
+ for text in texts:
780
+ source = str(text or "")
781
+ if not source:
782
+ continue
783
+ for pattern in _CODEX_WORKAROUND_PATTERNS:
784
+ for match in pattern.finditer(source):
785
+ snippet = match.group(0).strip()
786
+ if not snippet:
787
+ continue
788
+ lowered = snippet.lower()
789
+ if any(hint in lowered for hint in _CODEX_WORKAROUND_NEGATION_HINTS):
790
+ continue
791
+ return snippet
792
+ return None
793
+
794
+
795
+ def _read_text_if_exists(path: Path) -> str:
796
+ try:
797
+ if not path.exists():
798
+ return ""
799
+ return path.read_text(encoding="utf-8", errors="replace").strip()
800
+ except Exception:
801
+ return ""
802
+
803
+
804
+ def _terminate_child(signum: int, _frame: Any) -> None:
805
+ global _INTERRUPTED_SIGNAL
806
+ _INTERRUPTED_SIGNAL = signum
807
+ _terminate_active_child()
808
+
809
+
810
+ def _install_signal_handlers() -> None:
811
+ for sig in (signal.SIGTERM, signal.SIGINT):
812
+ try:
813
+ signal.signal(sig, _terminate_child)
814
+ except Exception:
815
+ pass
816
+
817
+
818
+ def _run_codex_task(
819
+ repo: str,
820
+ instruction: str,
821
+ supplemental_guidance: List[str],
822
+ ) -> Dict[str, Any]:
823
+ global _ACTIVE_CHILD, _INTERRUPTED_SIGNAL
824
+ _INTERRUPTED_SIGNAL = None
825
+ _install_signal_handlers()
826
+
827
+ if not _is_git_repo(repo):
828
+ return {
829
+ "ok": False,
830
+ "summary": "openai_codex requires a git repository",
831
+ "stderr": (
832
+ f"Refusing to run codex in a non-git directory: {repo}. "
833
+ "Validate repo/worktree setup before dispatching this backend."
834
+ ),
835
+ "exitCode": 2,
836
+ }
837
+
838
+ runtime_config = OpenAICodexRuntimeConfig.from_sources()
839
+ codex_cmd_prefix = _resolve_codex_command_prefix(runtime_config)
840
+ if not codex_cmd_prefix:
841
+ return {
842
+ "ok": False,
843
+ "summary": "openai_codex CLI is not installed",
844
+ "stderr": (
845
+ "Could not find a runnable Codex command. "
846
+ "Expected one of: `bunx --yes @openai/codex` or `codex` in PATH. "
847
+ "You can also set PUSHPALS_OPENAI_CODEX_BIN explicitly."
848
+ ),
849
+ "exitCode": 3,
850
+ }
851
+
852
+ configured_model, api_key, base_url = resolve_llm_config(DEFAULT_CODEX_MODEL, logger=log)
853
+ auth_mode_raw = runtime_config.auth_mode
854
+ auth_mode_configured = _normalize_auth_mode(auth_mode_raw)
855
+ model = _safe_model_for_codex(configured_model, base_url)
856
+ approval = _normalize_choice(
857
+ runtime_config.approval_policy,
858
+ _VALID_APPROVAL_POLICIES,
859
+ "never",
860
+ env_name="workerpals.openai_codex.approval_policy",
861
+ )
862
+ sandbox = _normalize_choice(
863
+ runtime_config.sandbox,
864
+ _VALID_SANDBOX_POLICIES,
865
+ "workspace-write",
866
+ env_name="workerpals.openai_codex.sandbox",
867
+ )
868
+ color = _normalize_choice(
869
+ runtime_config.color,
870
+ _VALID_COLORS,
871
+ "never",
872
+ env_name="workerpals.openai_codex.color",
873
+ )
874
+ # JSON event output is noisy by default; prefer plain text + output-last-message.
875
+ use_json = runtime_config.json_output
876
+ reasoning_effort = _resolve_reasoning_effort(runtime_config)
877
+ communicate_timeout_s = _resolve_communicate_timeout_seconds(runtime_config)
878
+ prompt = _build_instruction(instruction, supplemental_guidance)
879
+ baseline_changes = summarize_git_changes(repo)
880
+
881
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-") as tmp_dir:
882
+ last_message_path = Path(tmp_dir) / "codex-last-message.txt"
883
+ cmd: List[str] = [
884
+ *codex_cmd_prefix,
885
+ "-c",
886
+ f'model_reasoning_effort="{reasoning_effort}"',
887
+ "-a",
888
+ approval,
889
+ "exec",
890
+ "-s",
891
+ sandbox,
892
+ "--color",
893
+ color,
894
+ "--output-last-message",
895
+ str(last_message_path),
896
+ ]
897
+ if use_json:
898
+ cmd.append("--json")
899
+ if model:
900
+ cmd.extend(["-m", model])
901
+ cmd.append("-")
902
+
903
+ env = os.environ.copy()
904
+ env["PYTHONIOENCODING"] = "utf-8"
905
+ env["PUSHPALS_REPO_PATH"] = repo
906
+ env["PUSHPALS_ASSIGNED_REPO_ROOT"] = repo
907
+ existing_openai_key = (env.get("OPENAI_API_KEY") or "").strip()
908
+ llm_key = api_key.strip()
909
+ if llm_key.lower() == "lmstudio":
910
+ llm_key = ""
911
+ auth_mode = auth_mode_configured
912
+ if auth_mode == "auto":
913
+ auth_mode = "api_key" if (llm_key or existing_openai_key) else "chatgpt"
914
+ log.info(f"Codex auth mode: {auth_mode} (configured={auth_mode_configured})")
915
+
916
+ existing_openai_base = (
917
+ env.get("OPENAI_BASE_URL", "").strip() or env.get("OPENAI_API_BASE", "").strip()
918
+ )
919
+ override_base = runtime_config.base_url_override
920
+ effective_base = ""
921
+
922
+ if auth_mode == "chatgpt":
923
+ if llm_key or existing_openai_key:
924
+ log.info(
925
+ "ChatGPT auth mode selected; ignoring OPENAI_API_KEY to use Codex CLI login credentials."
926
+ )
927
+ if override_base or existing_openai_base or base_url:
928
+ log.info("ChatGPT auth mode selected; ignoring OPENAI_BASE_URL/OPENAI_API_BASE overrides.")
929
+ env.pop("OPENAI_API_KEY", None)
930
+ env.pop("OPENAI_BASE_URL", None)
931
+ env.pop("OPENAI_API_BASE", None)
932
+ login_status = _run_codex_login_status(codex_cmd_prefix, repo, env)
933
+ if not login_status.get("ok"):
934
+ detail = (
935
+ str(login_status.get("stderr") or "").strip()
936
+ or str(login_status.get("stdout") or "").strip()
937
+ or "codex login status returned non-zero"
938
+ )
939
+ return {
940
+ "ok": False,
941
+ "summary": "openai_codex chatgpt auth is not ready",
942
+ "stdout": _truncate(str(login_status.get("stdout") or "")),
943
+ "stderr": _truncate(
944
+ "Codex CLI is not logged in for ChatGPT subscription mode. "
945
+ "Run `bunx --yes @openai/codex login` on the host (no global install needed), "
946
+ "complete browser sign-in, then retry.\n"
947
+ f"Details: {detail}"
948
+ ),
949
+ "exitCode": int(login_status.get("exitCode") or 1),
950
+ }
951
+ else:
952
+ final_key = llm_key or existing_openai_key
953
+ if not final_key:
954
+ return {
955
+ "ok": False,
956
+ "summary": "openai_codex api_key auth requires OPENAI_API_KEY",
957
+ "stderr": (
958
+ "API-key auth mode selected, but no API key is available. "
959
+ "Set OPENAI_API_KEY (or WORKERPALS_LLM_API_KEY), "
960
+ "or set PUSHPALS_OPENAI_CODEX_AUTH_MODE=chatgpt."
961
+ ),
962
+ "exitCode": 2,
963
+ }
964
+ env["OPENAI_API_KEY"] = final_key
965
+ effective_base = override_base or base_url
966
+ if (
967
+ not override_base
968
+ and not existing_openai_base
969
+ and looks_local_base_url(base_url)
970
+ and (env.get("OPENAI_API_KEY") or "").strip()
971
+ ):
972
+ # If an OpenAI key exists but base URL came from local worker LLM config,
973
+ # prefer Codex/OpenAI defaults unless explicitly overridden.
974
+ log.info(
975
+ "Detected local worker LLM endpoint with OPENAI_API_KEY present; "
976
+ "using Codex default OpenAI endpoint (set PUSHPALS_OPENAI_CODEX_BASE_URL "
977
+ "to force local)."
978
+ )
979
+ effective_base = ""
980
+ if effective_base:
981
+ env["OPENAI_BASE_URL"] = effective_base
982
+ env["OPENAI_API_BASE"] = effective_base
983
+ else:
984
+ env.pop("OPENAI_BASE_URL", None)
985
+ env.pop("OPENAI_API_BASE", None)
986
+
987
+ log.info(f"Starting codex exec in {repo}")
988
+ log.debug(f"Codex command: {' '.join(codex_cmd_prefix)}")
989
+ log.debug(f"Model: {model}")
990
+ base_for_log = (
991
+ env.get("OPENAI_BASE_URL", "").strip()
992
+ or env.get("OPENAI_API_BASE", "").strip()
993
+ or "<default>"
994
+ )
995
+ log.debug(f"Base URL: {base_for_log}")
996
+ if communicate_timeout_s:
997
+ log.debug(f"communicate timeout: {communicate_timeout_s}s")
998
+
999
+ proc = subprocess.Popen(
1000
+ cmd,
1001
+ cwd=repo,
1002
+ env=env,
1003
+ stdout=subprocess.PIPE,
1004
+ stderr=subprocess.PIPE,
1005
+ stdin=subprocess.PIPE,
1006
+ text=True,
1007
+ encoding="utf-8",
1008
+ errors="replace",
1009
+ )
1010
+ _ACTIVE_CHILD = proc
1011
+ started_at = time.monotonic()
1012
+ progress_interval_s = _resolve_progress_log_interval_seconds(runtime_config)
1013
+
1014
+ stdout_chunks: List[str] = []
1015
+ stderr_chunks: List[str] = []
1016
+ stdout_trace_state = _empty_codex_trace()
1017
+ trace_lock = threading.Lock()
1018
+ last_activity_at = {"ts": started_at}
1019
+
1020
+ def _drain_stdout() -> None:
1021
+ stream = proc.stdout
1022
+ if stream is None:
1023
+ return
1024
+ try:
1025
+ for chunk in iter(stream.readline, ""):
1026
+ if chunk == "":
1027
+ break
1028
+ stdout_chunks.append(chunk)
1029
+ line = chunk.strip()
1030
+ if not line:
1031
+ continue
1032
+ with trace_lock:
1033
+ last_activity_at["ts"] = time.monotonic()
1034
+ _record_live_codex_stdout_line(line, use_json, stdout_trace_state)
1035
+ except Exception:
1036
+ pass
1037
+ finally:
1038
+ try:
1039
+ stream.close()
1040
+ except Exception:
1041
+ pass
1042
+
1043
+ def _drain_stderr() -> None:
1044
+ stream = proc.stderr
1045
+ if stream is None:
1046
+ return
1047
+ try:
1048
+ for chunk in iter(stream.readline, ""):
1049
+ if chunk == "":
1050
+ break
1051
+ stderr_chunks.append(chunk)
1052
+ except Exception:
1053
+ pass
1054
+ finally:
1055
+ try:
1056
+ stream.close()
1057
+ except Exception:
1058
+ pass
1059
+
1060
+ stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
1061
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
1062
+ stdout_thread.start()
1063
+ stderr_thread.start()
1064
+
1065
+ if proc.stdin is not None:
1066
+ try:
1067
+ proc.stdin.write(prompt)
1068
+ proc.stdin.close()
1069
+ except Exception:
1070
+ pass
1071
+
1072
+ deadline = (
1073
+ started_at + float(communicate_timeout_s)
1074
+ if communicate_timeout_s and communicate_timeout_s > 0
1075
+ else None
1076
+ )
1077
+ next_progress_at = started_at + float(progress_interval_s)
1078
+ timed_out = False
1079
+
1080
+ while proc.poll() is None:
1081
+ now = time.monotonic()
1082
+ if deadline is not None and now >= deadline:
1083
+ timed_out = True
1084
+ _terminate_active_child()
1085
+ break
1086
+
1087
+ if now >= next_progress_at:
1088
+ elapsed = int(max(0.0, now - started_at))
1089
+ with trace_lock:
1090
+ last_event = float(last_activity_at.get("ts", started_at))
1091
+ valid_json = to_int(stdout_trace_state.get("valid_json"), 0)
1092
+ total_lines = to_int(stdout_trace_state.get("line_count"), 0)
1093
+ idle_for = int(max(0.0, now - last_event))
1094
+ if use_json:
1095
+ log.info(
1096
+ f"codex exec still running ({elapsed}s elapsed, json_events={valid_json}, idle={idle_for}s)"
1097
+ )
1098
+ else:
1099
+ log.info(
1100
+ f"codex exec still running ({elapsed}s elapsed, stdout_lines={total_lines}, idle={idle_for}s)"
1101
+ )
1102
+ next_progress_at = now + float(progress_interval_s)
1103
+
1104
+ time.sleep(1.0)
1105
+
1106
+ try:
1107
+ proc.wait(timeout=5)
1108
+ except Exception:
1109
+ try:
1110
+ proc.kill()
1111
+ proc.wait(timeout=5)
1112
+ except Exception:
1113
+ pass
1114
+
1115
+ stdout_thread.join(timeout=2)
1116
+ stderr_thread.join(timeout=2)
1117
+
1118
+ return_code = proc.returncode
1119
+ _ACTIVE_CHILD = None
1120
+ elapsed_total = int(max(0.0, time.monotonic() - started_at))
1121
+ log.info(f"codex exec finished in {elapsed_total}s")
1122
+
1123
+ stdout = "".join(stdout_chunks)
1124
+ stderr = "".join(stderr_chunks)
1125
+ stdout_trace = _finalize_codex_stdout_trace(stdout_trace_state, use_json)
1126
+ trace_excerpt = _format_codex_trace_excerpt(stdout_trace)
1127
+ _log_stderr(stderr)
1128
+
1129
+ if timed_out:
1130
+ detail = (
1131
+ f"codex exec timed out after {communicate_timeout_s}s"
1132
+ if communicate_timeout_s
1133
+ else "codex exec timed out"
1134
+ )
1135
+ if trace_excerpt:
1136
+ detail = f"{detail}\n{trace_excerpt}"
1137
+ return {
1138
+ "ok": False,
1139
+ "summary": "openai_codex execution timed out",
1140
+ "stdout": _truncate(stdout),
1141
+ "stderr": _truncate(f"{detail}\n{stderr}".strip()),
1142
+ "exitCode": 124,
1143
+ }
1144
+
1145
+ last_message = _read_text_if_exists(last_message_path)
1146
+ log_git_status(repo, log)
1147
+
1148
+ if _INTERRUPTED_SIGNAL is not None:
1149
+ return {
1150
+ "ok": False,
1151
+ "summary": f"openai_codex interrupted by signal {_INTERRUPTED_SIGNAL}",
1152
+ "stdout": _truncate(stdout),
1153
+ "stderr": _truncate(stderr),
1154
+ "exitCode": 128 + int(_INTERRUPTED_SIGNAL),
1155
+ }
1156
+
1157
+ if return_code is None:
1158
+ return {
1159
+ "ok": False,
1160
+ "summary": "openai_codex execution ended without a process return code",
1161
+ "stdout": _truncate(stdout),
1162
+ "stderr": _truncate(stderr),
1163
+ "exitCode": 1,
1164
+ }
1165
+
1166
+ exit_code = int(return_code)
1167
+
1168
+ if exit_code != 0:
1169
+ detail = stderr.strip() or stdout.strip() or "codex exec exited with a non-zero status"
1170
+ if last_message:
1171
+ detail = f"{detail}\nLast assistant message:\n{last_message}"
1172
+ if trace_excerpt:
1173
+ detail = f"{detail}\n{trace_excerpt}"
1174
+ return {
1175
+ "ok": False,
1176
+ "summary": f"openai_codex execution failed (exit {exit_code})",
1177
+ "stdout": _truncate(stdout),
1178
+ "stderr": _truncate(detail),
1179
+ "exitCode": exit_code,
1180
+ }
1181
+
1182
+ policy_signal = _detect_codex_workaround_signal(last_message)
1183
+ if not policy_signal and not last_message.strip():
1184
+ # Fallback only when the CLI did not emit a final assistant message.
1185
+ policy_signal = _detect_codex_workaround_signal(stdout)
1186
+ if policy_signal:
1187
+ detail = (
1188
+ "Codex CLI is mandatory in this backend, but worker output suggests a workaround "
1189
+ f"instead of hard-failing: {policy_signal!r}. "
1190
+ "Return an explicit failure if Codex auth/execution is unavailable."
1191
+ )
1192
+ if last_message:
1193
+ detail = f"{detail}\nLast assistant message:\n{last_message}"
1194
+ if trace_excerpt:
1195
+ detail = f"{detail}\n{trace_excerpt}"
1196
+ return {
1197
+ "ok": False,
1198
+ "summary": "openai_codex policy violation: Codex CLI workaround detected",
1199
+ "stdout": _truncate(stdout),
1200
+ "stderr": _truncate(detail),
1201
+ "exitCode": 5,
1202
+ }
1203
+
1204
+ changed_paths = summarize_git_changes(repo)
1205
+ delta = [p for p in changed_paths if p not in baseline_changes]
1206
+ effective = delta if delta else changed_paths
1207
+ stdout_parts: List[str] = []
1208
+ if last_message:
1209
+ stdout_parts.append(last_message)
1210
+ elif trace_excerpt:
1211
+ stdout_parts.append(trace_excerpt)
1212
+ if effective:
1213
+ listed = "\n".join(f"- {path}" for path in effective[:40])
1214
+ if len(effective) > 40:
1215
+ listed += "\n- ..."
1216
+ stdout_parts.append(f"Changed files:\n{listed}")
1217
+ return {
1218
+ "ok": True,
1219
+ "summary": f"Executed task and modified {len(effective)} file(s)",
1220
+ "stdout": "\n\n".join(stdout_parts),
1221
+ "stderr": "",
1222
+ "exitCode": 0,
1223
+ }
1224
+
1225
+ if not stdout_parts:
1226
+ stdout_parts.append("No modified files were detected after execution.")
1227
+ return {
1228
+ "ok": True,
1229
+ "summary": "Executed task via openai_codex (no file changes detected)",
1230
+ "stdout": "\n\n".join(stdout_parts),
1231
+ "stderr": "",
1232
+ "exitCode": 0,
1233
+ }
1234
+
1235
+
1236
+ def main() -> int:
1237
+ try:
1238
+ task = parse_task_execute_payload(sys.argv, logger=log)
1239
+ result = _run_codex_task(
1240
+ task.repo,
1241
+ task.instruction,
1242
+ task.supplemental_guidance,
1243
+ )
1244
+ except Exception as exc:
1245
+ result = {
1246
+ "ok": False,
1247
+ "summary": "openai_codex wrapper crashed while executing task.execute",
1248
+ "stdout": "",
1249
+ "stderr": traceback.format_exc(),
1250
+ "exitCode": 1,
1251
+ "error": to_single_line(exc, 300),
1252
+ }
1253
+
1254
+ emit(result)
1255
+ return 0 if bool(result.get("ok")) else to_int(result.get("exitCode"), 1)
1256
+
1257
+
1258
+ if __name__ == "__main__":
1259
+ raise SystemExit(main())