@pushpalsdev/cli 1.0.93 → 1.0.95

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.
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import json
11
11
  import os
12
12
  import re
13
- from shutil import which
13
+ from shutil import rmtree, which
14
14
  import shlex
15
15
  import signal
16
16
  import subprocess
@@ -21,7 +21,7 @@ import time
21
21
  import traceback
22
22
  from dataclasses import dataclass
23
23
  from pathlib import Path
24
- from typing import Any, Dict, List, Optional
24
+ from typing import Any, Dict, List, Optional, Tuple
25
25
 
26
26
  _SHARED_DIR = Path(__file__).resolve().parents[1] / "shared"
27
27
  if str(_SHARED_DIR) not in sys.path:
@@ -386,6 +386,87 @@ def _is_git_repo(repo: str) -> bool:
386
386
  return False
387
387
 
388
388
 
389
+ def _codex_project_config_roots(repo: str, env: Dict[str, str]) -> List[Path]:
390
+ roots: List[Path] = []
391
+ seen: set[str] = set()
392
+
393
+ def add(raw: object) -> None:
394
+ text = str(raw or "").strip()
395
+ if not text:
396
+ return
397
+ try:
398
+ path = Path(text).resolve()
399
+ except Exception:
400
+ return
401
+ key = str(path)
402
+ if key in seen:
403
+ return
404
+ seen.add(key)
405
+ roots.append(path)
406
+
407
+ add(repo)
408
+ try:
409
+ proc = subprocess.run(
410
+ ["git", "rev-parse", "--show-toplevel"],
411
+ cwd=repo,
412
+ capture_output=True,
413
+ text=True,
414
+ timeout=10,
415
+ check=False,
416
+ )
417
+ if proc.returncode == 0:
418
+ add((proc.stdout or "").strip())
419
+ except Exception:
420
+ pass
421
+
422
+ for key in (
423
+ "PUSHPALS_REPO_ROOT_OVERRIDE",
424
+ "PUSHPALS_PROJECT_ROOT_OVERRIDE",
425
+ "PUSHPALS_ASSIGNED_REPO_ROOT",
426
+ "PUSHPALS_REPO_PATH",
427
+ ):
428
+ add(env.get(key))
429
+ return roots
430
+
431
+
432
+ def _mask_repo_local_codex_files(repo: str, env: Dict[str, str]) -> List[Tuple[Path, Path]]:
433
+ masked: List[Tuple[Path, Path]] = []
434
+ for root in _codex_project_config_roots(repo, env):
435
+ codex_path = root / ".codex"
436
+ if not os.path.lexists(codex_path):
437
+ continue
438
+ if codex_path.is_dir():
439
+ continue
440
+ backup = root / f".codex.pushpals-masked-{os.getpid()}-{len(masked)}"
441
+ suffix = 0
442
+ while os.path.lexists(backup):
443
+ suffix += 1
444
+ backup = root / f".codex.pushpals-masked-{os.getpid()}-{len(masked)}-{suffix}"
445
+ try:
446
+ os.replace(codex_path, backup)
447
+ masked.append((codex_path, backup))
448
+ log.info(
449
+ f"Temporarily masked repo-local .codex file so Codex CLI can use CODEX_HOME: {codex_path}"
450
+ )
451
+ except Exception as exc:
452
+ log.warning(f"Failed to mask repo-local .codex file {codex_path}: {exc}")
453
+ return masked
454
+
455
+
456
+ def _restore_repo_local_codex_files(masked: List[Tuple[Path, Path]]) -> None:
457
+ for codex_path, backup in reversed(masked):
458
+ try:
459
+ if os.path.lexists(codex_path):
460
+ if codex_path.is_dir() and not codex_path.is_symlink():
461
+ rmtree(codex_path)
462
+ else:
463
+ codex_path.unlink()
464
+ if os.path.lexists(backup):
465
+ os.replace(backup, codex_path)
466
+ except Exception as exc:
467
+ log.warning(f"Failed to restore repo-local .codex file {codex_path}: {exc}")
468
+
469
+
389
470
  def _resolve_codex_command_prefix(config: OpenAICodexRuntimeConfig) -> List[str]:
390
471
  override_json = config.codex_bin_json
391
472
  if override_json:
@@ -698,7 +779,7 @@ def _summarize_json_event(obj: Dict[str, Any]) -> str:
698
779
  event_type = "event"
699
780
  # Skip noisy streaming deltas unless they contain meaningful text fragments.
700
781
  delta_like = event_type.endswith(".delta") or event_type.endswith("_delta")
701
- # Reasoning/thinking events are always surfaced they show the model's reasoning process.
782
+ # Reasoning/thinking events are always surfaced because they show the model's reasoning process.
702
783
  reasoning_like = _contains_reasoning_marker(event_type) or _event_contains_reasoning(obj)
703
784
 
704
785
  tool_name = ""
@@ -1487,7 +1568,11 @@ def _run_codex_task(
1487
1568
  env.pop("OPENAI_API_KEY", None)
1488
1569
  env.pop("OPENAI_BASE_URL", None)
1489
1570
  env.pop("OPENAI_API_BASE", None)
1490
- login_status = _run_codex_login_status(codex_cmd_prefix, repo, env)
1571
+ codex_project_mask = _mask_repo_local_codex_files(repo, env)
1572
+ try:
1573
+ login_status = _run_codex_login_status(codex_cmd_prefix, repo, env)
1574
+ finally:
1575
+ _restore_repo_local_codex_files(codex_project_mask)
1491
1576
  if not login_status.get("ok"):
1492
1577
  detail = (
1493
1578
  str(login_status.get("stderr") or "").strip()
@@ -1554,148 +1639,152 @@ def _run_codex_task(
1554
1639
  if communicate_timeout_s:
1555
1640
  log.debug(f"communicate timeout: {communicate_timeout_s}s")
1556
1641
 
1557
- proc = subprocess.Popen(
1558
- cmd,
1559
- cwd=repo,
1560
- env=env,
1561
- stdout=subprocess.PIPE,
1562
- stderr=subprocess.PIPE,
1563
- stdin=subprocess.PIPE,
1564
- text=True,
1565
- encoding="utf-8",
1566
- errors="replace",
1567
- )
1568
- _ACTIVE_CHILD = proc
1569
- started_at = time.monotonic()
1570
- progress_interval_s = _resolve_progress_log_interval_seconds(runtime_config)
1571
-
1572
- stdout_chunks: List[str] = []
1573
- stderr_chunks: List[str] = []
1574
- stdout_trace_state = _empty_codex_trace()
1575
- trace_lock = threading.Lock()
1576
- last_activity_at = {"ts": started_at}
1577
- wrapper_rejection_state: Dict[str, Any] = {"count": 0, "commands": []}
1578
-
1579
- def _drain_stdout() -> None:
1580
- stream = proc.stdout
1581
- if stream is None:
1582
- return
1583
- try:
1584
- for chunk in iter(stream.readline, ""):
1585
- if chunk == "":
1586
- break
1587
- stdout_chunks.append(chunk)
1588
- line = chunk.strip()
1589
- if not line:
1590
- continue
1591
- with trace_lock:
1592
- last_activity_at["ts"] = time.monotonic()
1593
- _record_live_codex_stdout_line(line, use_json, stdout_trace_state)
1594
- except Exception:
1595
- pass
1596
- finally:
1642
+ codex_project_mask = _mask_repo_local_codex_files(repo, env)
1643
+ try:
1644
+ proc = subprocess.Popen(
1645
+ cmd,
1646
+ cwd=repo,
1647
+ env=env,
1648
+ stdout=subprocess.PIPE,
1649
+ stderr=subprocess.PIPE,
1650
+ stdin=subprocess.PIPE,
1651
+ text=True,
1652
+ encoding="utf-8",
1653
+ errors="replace",
1654
+ )
1655
+ _ACTIVE_CHILD = proc
1656
+ started_at = time.monotonic()
1657
+ progress_interval_s = _resolve_progress_log_interval_seconds(runtime_config)
1658
+
1659
+ stdout_chunks: List[str] = []
1660
+ stderr_chunks: List[str] = []
1661
+ stdout_trace_state = _empty_codex_trace()
1662
+ trace_lock = threading.Lock()
1663
+ last_activity_at = {"ts": started_at}
1664
+ wrapper_rejection_state: Dict[str, Any] = {"count": 0, "commands": []}
1665
+
1666
+ def _drain_stdout() -> None:
1667
+ stream = proc.stdout
1668
+ if stream is None:
1669
+ return
1597
1670
  try:
1598
- stream.close()
1671
+ for chunk in iter(stream.readline, ""):
1672
+ if chunk == "":
1673
+ break
1674
+ stdout_chunks.append(chunk)
1675
+ line = chunk.strip()
1676
+ if not line:
1677
+ continue
1678
+ with trace_lock:
1679
+ last_activity_at["ts"] = time.monotonic()
1680
+ _record_live_codex_stdout_line(line, use_json, stdout_trace_state)
1599
1681
  except Exception:
1600
1682
  pass
1601
-
1602
- def _drain_stderr() -> None:
1603
- stream = proc.stderr
1604
- if stream is None:
1605
- return
1606
- try:
1607
- for chunk in iter(stream.readline, ""):
1608
- if chunk == "":
1609
- break
1610
- stderr_chunks.append(chunk)
1611
- rejected_commands = _collect_disallowed_shell_wrapper_rejections(chunk)
1612
- if rejected_commands:
1613
- with trace_lock:
1614
- wrapper_rejection_state["count"] = to_int(
1615
- wrapper_rejection_state.get("count"), 0
1616
- ) + len(rejected_commands)
1617
- tracked = wrapper_rejection_state.get("commands")
1618
- if not isinstance(tracked, list):
1619
- tracked = []
1620
- for command in rejected_commands:
1621
- lowered = command.lower()
1622
- if any(str(item).lower() == lowered for item in tracked):
1623
- continue
1624
- tracked.append(command)
1625
- wrapper_rejection_state["commands"] = tracked[:6]
1626
- except Exception:
1627
- pass
1628
- finally:
1683
+ finally:
1684
+ try:
1685
+ stream.close()
1686
+ except Exception:
1687
+ pass
1688
+
1689
+ def _drain_stderr() -> None:
1690
+ stream = proc.stderr
1691
+ if stream is None:
1692
+ return
1629
1693
  try:
1630
- stream.close()
1694
+ for chunk in iter(stream.readline, ""):
1695
+ if chunk == "":
1696
+ break
1697
+ stderr_chunks.append(chunk)
1698
+ rejected_commands = _collect_disallowed_shell_wrapper_rejections(chunk)
1699
+ if rejected_commands:
1700
+ with trace_lock:
1701
+ wrapper_rejection_state["count"] = to_int(
1702
+ wrapper_rejection_state.get("count"), 0
1703
+ ) + len(rejected_commands)
1704
+ tracked = wrapper_rejection_state.get("commands")
1705
+ if not isinstance(tracked, list):
1706
+ tracked = []
1707
+ for command in rejected_commands:
1708
+ lowered = command.lower()
1709
+ if any(str(item).lower() == lowered for item in tracked):
1710
+ continue
1711
+ tracked.append(command)
1712
+ wrapper_rejection_state["commands"] = tracked[:6]
1713
+ except Exception:
1714
+ pass
1715
+ finally:
1716
+ try:
1717
+ stream.close()
1718
+ except Exception:
1719
+ pass
1720
+
1721
+ stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
1722
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
1723
+ stdout_thread.start()
1724
+ stderr_thread.start()
1725
+
1726
+ if proc.stdin is not None:
1727
+ try:
1728
+ proc.stdin.write(prompt)
1729
+ proc.stdin.close()
1631
1730
  except Exception:
1632
1731
  pass
1633
1732
 
1634
- stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
1635
- stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
1636
- stdout_thread.start()
1637
- stderr_thread.start()
1638
-
1639
- if proc.stdin is not None:
1640
- try:
1641
- proc.stdin.write(prompt)
1642
- proc.stdin.close()
1643
- except Exception:
1644
- pass
1645
-
1646
- deadline = (
1647
- started_at + float(communicate_timeout_s)
1648
- if communicate_timeout_s and communicate_timeout_s > 0
1649
- else None
1650
- )
1651
- next_progress_at = started_at + float(progress_interval_s)
1652
- timed_out = False
1653
- command_policy_rejection_loop = False
1654
-
1655
- while proc.poll() is None:
1656
- now = time.monotonic()
1657
- if deadline is not None and now >= deadline:
1658
- timed_out = True
1659
- _terminate_active_child()
1660
- break
1733
+ deadline = (
1734
+ started_at + float(communicate_timeout_s)
1735
+ if communicate_timeout_s and communicate_timeout_s > 0
1736
+ else None
1737
+ )
1738
+ next_progress_at = started_at + float(progress_interval_s)
1739
+ timed_out = False
1740
+ command_policy_rejection_loop = False
1661
1741
 
1662
- with trace_lock:
1663
- wrapper_rejections = to_int(wrapper_rejection_state.get("count"), 0)
1664
- if wrapper_rejections >= 3:
1665
- command_policy_rejection_loop = True
1666
- _terminate_active_child()
1667
- break
1742
+ while proc.poll() is None:
1743
+ now = time.monotonic()
1744
+ if deadline is not None and now >= deadline:
1745
+ timed_out = True
1746
+ _terminate_active_child()
1747
+ break
1668
1748
 
1669
- if now >= next_progress_at:
1670
- elapsed = int(max(0.0, now - started_at))
1671
1749
  with trace_lock:
1672
- last_event = float(last_activity_at.get("ts", started_at))
1673
- valid_json = to_int(stdout_trace_state.get("valid_json"), 0)
1674
- total_lines = to_int(stdout_trace_state.get("line_count"), 0)
1675
- idle_for = int(max(0.0, now - last_event))
1676
- if use_json:
1677
- log.info(
1678
- f"codex exec still running ({elapsed}s elapsed, json_events={valid_json}, idle={idle_for}s)"
1679
- )
1680
- else:
1681
- log.info(
1682
- f"codex exec still running ({elapsed}s elapsed, stdout_lines={total_lines}, idle={idle_for}s)"
1683
- )
1684
- next_progress_at = now + float(progress_interval_s)
1750
+ wrapper_rejections = to_int(wrapper_rejection_state.get("count"), 0)
1751
+ if wrapper_rejections >= 3:
1752
+ command_policy_rejection_loop = True
1753
+ _terminate_active_child()
1754
+ break
1755
+
1756
+ if now >= next_progress_at:
1757
+ elapsed = int(max(0.0, now - started_at))
1758
+ with trace_lock:
1759
+ last_event = float(last_activity_at.get("ts", started_at))
1760
+ valid_json = to_int(stdout_trace_state.get("valid_json"), 0)
1761
+ total_lines = to_int(stdout_trace_state.get("line_count"), 0)
1762
+ idle_for = int(max(0.0, now - last_event))
1763
+ if use_json:
1764
+ log.info(
1765
+ f"codex exec still running ({elapsed}s elapsed, json_events={valid_json}, idle={idle_for}s)"
1766
+ )
1767
+ else:
1768
+ log.info(
1769
+ f"codex exec still running ({elapsed}s elapsed, stdout_lines={total_lines}, idle={idle_for}s)"
1770
+ )
1771
+ next_progress_at = now + float(progress_interval_s)
1685
1772
 
1686
- time.sleep(1.0)
1773
+ time.sleep(1.0)
1687
1774
 
1688
- try:
1689
- proc.wait(timeout=5)
1690
- except Exception:
1691
1775
  try:
1692
- proc.kill()
1693
1776
  proc.wait(timeout=5)
1694
1777
  except Exception:
1695
- pass
1778
+ try:
1779
+ proc.kill()
1780
+ proc.wait(timeout=5)
1781
+ except Exception:
1782
+ pass
1696
1783
 
1697
- stdout_thread.join(timeout=2)
1698
- stderr_thread.join(timeout=2)
1784
+ stdout_thread.join(timeout=2)
1785
+ stderr_thread.join(timeout=2)
1786
+ finally:
1787
+ _restore_repo_local_codex_files(codex_project_mask)
1699
1788
 
1700
1789
  return_code = proc.returncode
1701
1790
  _ACTIVE_CHILD = None
@@ -33,7 +33,9 @@ from openai_codex_executor import (
33
33
  _detect_codex_workaround_signal,
34
34
  _extract_usage_counts,
35
35
  _load_prompt_template,
36
+ _mask_repo_local_codex_files,
36
37
  _repo_root_for_prompt_loading,
38
+ _restore_repo_local_codex_files,
37
39
  _resolve_codex_command_prefix,
38
40
  _unwrap_shell_wrapper_command,
39
41
  _usage_from_trace_or_estimate,
@@ -80,6 +82,61 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
80
82
  self.assertEqual(cfg.reasoning_effort, "xhigh")
81
83
  self.assertFalse(cfg.json_output)
82
84
 
85
+ def test_masks_and_restores_repo_local_codex_file(self) -> None:
86
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-mask-") as root:
87
+ repo = Path(root) / "repo"
88
+ repo.mkdir()
89
+ codex_file = repo / ".codex"
90
+ codex_file.write_text("tracked repo sentinel\n", encoding="utf-8")
91
+
92
+ masked = _mask_repo_local_codex_files(str(repo), {})
93
+ try:
94
+ self.assertFalse(codex_file.exists())
95
+ self.assertEqual(len(masked), 1)
96
+ self.assertTrue(masked[0][1].exists())
97
+ finally:
98
+ _restore_repo_local_codex_files(masked)
99
+
100
+ self.assertEqual(codex_file.read_text(encoding="utf-8"), "tracked repo sentinel\n")
101
+
102
+ def test_masks_project_root_override_codex_file_for_worktree_runs(self) -> None:
103
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-mask-root-") as root:
104
+ project_root = Path(root) / "project"
105
+ worktree = project_root / ".worktrees" / "job-123"
106
+ worktree.mkdir(parents=True)
107
+ root_codex_file = project_root / ".codex"
108
+ worktree_codex_file = worktree / ".codex"
109
+ root_codex_file.write_text("root sentinel\n", encoding="utf-8")
110
+ worktree_codex_file.write_text("worktree sentinel\n", encoding="utf-8")
111
+
112
+ masked = _mask_repo_local_codex_files(
113
+ str(worktree),
114
+ {"PUSHPALS_REPO_ROOT_OVERRIDE": str(project_root)},
115
+ )
116
+ try:
117
+ self.assertFalse(root_codex_file.exists())
118
+ self.assertFalse(worktree_codex_file.exists())
119
+ self.assertEqual(len(masked), 2)
120
+ finally:
121
+ _restore_repo_local_codex_files(masked)
122
+
123
+ self.assertEqual(root_codex_file.read_text(encoding="utf-8"), "root sentinel\n")
124
+ self.assertEqual(worktree_codex_file.read_text(encoding="utf-8"), "worktree sentinel\n")
125
+
126
+ def test_does_not_mask_repo_local_codex_directory(self) -> None:
127
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-mask-dir-") as root:
128
+ repo = Path(root) / "repo"
129
+ codex_dir = repo / ".codex"
130
+ codex_dir.mkdir(parents=True)
131
+ (codex_dir / "config.toml").write_text("[hooks]\n", encoding="utf-8")
132
+
133
+ masked = _mask_repo_local_codex_files(str(repo), {})
134
+ try:
135
+ self.assertEqual(masked, [])
136
+ self.assertTrue((codex_dir / "config.toml").exists())
137
+ finally:
138
+ _restore_repo_local_codex_files(masked)
139
+
83
140
  def test_reasoning_effort_defaults_to_extra_high_for_default_gpt_5_5(self) -> None:
84
141
  cfg = OpenAICodexRuntimeConfig.from_sources(
85
142
  SettingsResolver(env={}, config_loader=lambda: {}),