@pushpalsdev/cli 1.0.86 → 1.0.94

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 (27) hide show
  1. package/dist/pushpals-cli.js +1 -1
  2. package/package.json +2 -2
  3. package/runtime/prompts/remotebuddy/autonomy_ideation_system_prompt.md +2 -1
  4. package/runtime/prompts/remotebuddy/autonomy_planning_system_prompt.md +1 -1
  5. package/runtime/prompts/remotebuddy/remotebuddy_system_prompt.md +4 -4
  6. package/runtime/prompts/workerpals/miniswe_completion_requirement.md +1 -1
  7. package/runtime/prompts/workerpals/miniswe_explicit_targets_block.md +1 -1
  8. package/runtime/prompts/workerpals/openai_codex_task_execute_system_prompt.md +4 -1
  9. package/runtime/prompts/workerpals/openhands_minimal_system_prompt.j2 +3 -1
  10. package/runtime/prompts/workerpals/openhands_task_execute_system_prompt.md +2 -1
  11. package/runtime/prompts/workerpals/workerpals_system_prompt.md +2 -2
  12. package/runtime/sandbox/.pushpals-remotebuddy-fallback.js +248 -98
  13. package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +5 -34
  14. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +219 -130
  15. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +57 -0
  16. package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +3 -2
  17. package/runtime/sandbox/apps/workerpals/src/execute_job.ts +142 -134
  18. package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +70 -25
  19. package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +14 -8
  20. package/runtime/sandbox/packages/shared/src/communication.ts +4 -1
  21. package/runtime/sandbox/packages/shared/src/config.ts +1 -1
  22. package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -1
  23. package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +1 -1
  24. package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +4 -1
  25. package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +3 -1
  26. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +2 -1
  27. package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +2 -2
@@ -465,25 +465,9 @@ def _extract_write_globs_from_payload(payload: Optional[Dict[str, Any]]) -> List
465
465
 
466
466
 
467
467
  def _assert_write_allowed(repo: str, path: str, write_globs: Optional[List[str]]) -> None:
468
- if not write_globs:
469
- return
470
- normalized = _normalize_concrete_repo_path(repo, path)
471
- if not normalized:
472
- raise RuntimeError(f"Invalid write path for scope enforcement: {path!r}")
473
- for glob in write_globs:
474
- pattern = str(glob or "").strip()
475
- if not pattern:
476
- continue
477
- if any(ch in pattern for ch in "*?[]"):
478
- if fnmatch.fnmatchcase(normalized, pattern):
479
- return
480
- continue
481
- if normalized == pattern or normalized.startswith(pattern + "/"):
482
- return
483
- raise RuntimeError(
484
- "Scope violation: attempted write outside writeGlobs. "
485
- f"path={normalized!r} write_globs={write_globs!r}"
486
- )
468
+ # WorkerPal jobs run in isolated sandboxes. Scope hints are used for review
469
+ # relevance, not per-write filesystem enforcement.
470
+ return
487
471
 
488
472
 
489
473
  def _read_text_file(repo: str, path: str, max_chars: int = 60000) -> str:
@@ -1587,11 +1571,6 @@ def _broker_run(
1587
1571
  if expected_targets and changed_paths:
1588
1572
  changed_set = {str(p).strip().replace("\\", "/") for p in changed_paths}
1589
1573
  expected_set = {str(p).strip().replace("\\", "/") for p in expected_targets}
1590
- strict_target_match = bool(
1591
- explicit_target_set
1592
- and not any(t in {".", "/"} for t in explicit_target_set)
1593
- and not any(any(ch in t for ch in "*?[]") for t in explicit_target_set)
1594
- )
1595
1574
  matched = any(
1596
1575
  _target_hint_matches_changed_path(expected, changed)
1597
1576
  for expected in expected_set
@@ -1602,14 +1581,6 @@ def _broker_run(
1602
1581
  "Expected one of target paths to change, but observed different files. "
1603
1582
  f"expected={sorted(expected_set)} observed={sorted(changed_set)}"
1604
1583
  )
1605
- if strict_target_match:
1606
- return {
1607
- "ok": False,
1608
- "summary": "tool broker failed: changed files do not match explicit target paths",
1609
- "stdout": stdout + "\n\nChanged files:\n" + "\n".join(f"- {p}" for p in changed_paths),
1610
- "stderr": msg,
1611
- "exitCode": 3,
1612
- }
1613
1584
  stdout += "\n\nTarget-path mismatch (heuristic, non-fatal):\n" + msg
1614
1585
  if edits_made and not shell_validation_ran:
1615
1586
  stdout += (
@@ -1786,10 +1757,10 @@ def _run_miniswe_task(
1786
1757
  agent = None
1787
1758
  agent_messages: List[Dict[str, Any]] = []
1788
1759
  broker_enabled = _tool_broker_enabled(base_url)
1789
- prefer_broker_for_scoped_writes = bool(explicit_write_globs)
1760
+ prefer_broker_for_scoped_writes = False
1790
1761
  ran_primary_broker = False
1791
1762
  if prefer_broker_for_scoped_writes and broker_enabled:
1792
- log.info("Using tool broker shim for strict per-write scope enforcement.")
1763
+ log.info("Using tool broker shim for task execution.")
1793
1764
  broker_result = _run_broker_with_recovery()
1794
1765
  if not bool(broker_result.get("ok")):
1795
1766
  return {
@@ -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: {}),
@@ -352,9 +352,10 @@ def _build_path_handling_message(target_paths: List[str], repo: str) -> str:
352
352
  "- Prefer the repo-relative paths for shell commands.\n"
353
353
  "- If FileEditor rejects a repo-relative path, retry with the matching absolute path.\n"
354
354
  "- Do not run broad filesystem scans when concrete target paths are listed.\n"
355
- "Concrete target paths (repo-relative):\n"
355
+ "- These paths are starting points, not hard write boundaries; edit other behavior-owning files when needed and explain why.\n"
356
+ "Target path hints (repo-relative):\n"
356
357
  f"{listed_rel}\n"
357
- "Concrete target paths (absolute):\n"
358
+ "Target path hints (absolute):\n"
358
359
  f"{listed_abs}"
359
360
  )
360
361