@pushpalsdev/cli 1.0.49 → 1.0.51

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.49",
3
+ "version": "1.0.51",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,11 +77,16 @@ _CODEX_WORKAROUND_NEGATION_HINTS = (
77
77
  _REJECTED_EXEC_COMMAND_PATTERN = re.compile(r"exec_command failed for `([^`]+)`", re.IGNORECASE)
78
78
  _DISALLOWED_SHELL_WRAPPER_PREFIXES = (
79
79
  "/bin/bash -lc ",
80
+ "/bin/bash -c ",
80
81
  "bash -lc ",
82
+ "bash -c ",
81
83
  "sh -lc ",
84
+ "sh -c ",
82
85
  "cmd /c ",
83
86
  "powershell -command ",
87
+ "powershell.exe -command ",
84
88
  "pwsh -command ",
89
+ "pwsh.exe -command ",
85
90
  )
86
91
 
87
92
  _VALID_APPROVAL_POLICIES = {"untrusted", "on-failure", "on-request", "never"}
@@ -89,6 +94,12 @@ _VALID_SANDBOX_POLICIES = {"read-only", "workspace-write", "danger-full-access"}
89
94
  _VALID_COLORS = {"always", "never", "auto"}
90
95
  _VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"}
91
96
  _VALID_REASONING_EFFORTS = {"low", "medium", "high", "xhigh"}
97
+ _DIRECT_COMMAND_POLICY_GUIDANCE = (
98
+ "Command-router policy: use direct commands only. Do not invoke `/bin/bash -lc`, `bash -c`, "
99
+ "`sh -lc`, `cmd /c`, `powershell -Command`, or `pwsh -Command`. Run the direct command "
100
+ "instead, such as `pwd`, `git status --porcelain`, `git diff -- path`, `ls dir`, "
101
+ "`cat file`, `sed -n '1,160p' file`, or `bun test <path>`."
102
+ )
92
103
 
93
104
 
94
105
  def _model_supports_xhigh_reasoning(model: str) -> bool:
@@ -981,6 +992,81 @@ def _collect_disallowed_shell_wrapper_rejections(*texts: str) -> List[str]:
981
992
  return rejected
982
993
 
983
994
 
995
+ def _unwrap_shell_wrapper_command(command: str) -> str:
996
+ normalized = _normalize_command_text(command)
997
+ if not normalized:
998
+ return ""
999
+ try:
1000
+ parts = shlex.split(normalized, posix=True)
1001
+ except ValueError:
1002
+ return ""
1003
+ if len(parts) < 3:
1004
+ return ""
1005
+ executable = str(parts[0] or "").strip().lower()
1006
+ flag = str(parts[1] or "").strip().lower()
1007
+ if executable in {"/bin/bash", "bash", "sh"} and flag in {"-lc", "-c"}:
1008
+ return _normalize_command_text(" ".join(parts[2:]))
1009
+ if executable == "cmd" and flag == "/c":
1010
+ return _normalize_command_text(" ".join(parts[2:]))
1011
+ if executable in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"} and flag == "-command":
1012
+ return _normalize_command_text(" ".join(parts[2:]))
1013
+ return ""
1014
+
1015
+
1016
+ def _build_wrapper_recovery_guidance(rejected_commands: List[str]) -> str:
1017
+ direct_equivalents: List[str] = []
1018
+ seen: set[str] = set()
1019
+ for command in rejected_commands:
1020
+ direct = _unwrap_shell_wrapper_command(command)
1021
+ lowered = direct.lower()
1022
+ if not direct or lowered in seen:
1023
+ continue
1024
+ seen.add(lowered)
1025
+ direct_equivalents.append(f"- `{command}` -> `{direct}`")
1026
+ guidance_lines = [
1027
+ "Command-router recovery: the previous attempt retried disallowed shell wrappers.",
1028
+ "Retry once using direct commands only. Do not use `/bin/bash -lc`, `bash -c`, `sh -lc`, `cmd /c`, `powershell -Command`, `pwsh -Command`, pipelines, or chained shell snippets.",
1029
+ "If you need to inspect files or git state, run the direct command itself (for example `git diff --name-only`, `git status --porcelain`, `ls path`, `cat file`, or `sed -n '1,120p' file`).",
1030
+ ]
1031
+ if direct_equivalents:
1032
+ guidance_lines.append("Use these direct replacements for the rejected commands:")
1033
+ guidance_lines.extend(direct_equivalents[:6])
1034
+ return "\n".join(guidance_lines)
1035
+
1036
+
1037
+ def _merge_usage_records(first: Any, second: Any) -> Dict[str, Any]:
1038
+ first_record = first if isinstance(first, dict) else {}
1039
+ second_record = second if isinstance(second, dict) else {}
1040
+ if not first_record:
1041
+ return dict(second_record)
1042
+ if not second_record:
1043
+ return dict(first_record)
1044
+ prompt_tokens = to_int(first_record.get("promptTokens"), 0) + to_int(
1045
+ second_record.get("promptTokens"), 0
1046
+ )
1047
+ completion_tokens = to_int(first_record.get("completionTokens"), 0) + to_int(
1048
+ second_record.get("completionTokens"), 0
1049
+ )
1050
+ merged = dict(second_record)
1051
+ merged["promptTokens"] = prompt_tokens
1052
+ merged["completionTokens"] = completion_tokens
1053
+ merged["totalTokens"] = prompt_tokens + completion_tokens
1054
+ merged["estimated"] = bool(first_record.get("estimated")) or bool(second_record.get("estimated"))
1055
+ if not merged.get("backend"):
1056
+ merged["backend"] = first_record.get("backend")
1057
+ if not merged.get("modelId"):
1058
+ merged["modelId"] = first_record.get("modelId")
1059
+ return merged
1060
+
1061
+
1062
+ def _augment_supplemental_guidance(supplemental_guidance: List[str]) -> List[str]:
1063
+ normalized = [str(item or "").strip() for item in supplemental_guidance if str(item or "").strip()]
1064
+ joined = "\n".join(normalized).lower()
1065
+ if "direct commands only" in joined or "shell-wrapper" in joined or "/bin/bash -lc" in joined:
1066
+ return normalized
1067
+ return [_DIRECT_COMMAND_POLICY_GUIDANCE, *normalized]
1068
+
1069
+
984
1070
  def _read_text_if_exists(path: Path) -> str:
985
1071
  try:
986
1072
  if not path.exists():
@@ -1008,6 +1094,9 @@ def _run_codex_task(
1008
1094
  repo: str,
1009
1095
  instruction: str,
1010
1096
  supplemental_guidance: List[str],
1097
+ *,
1098
+ wrapper_recovery_attempt: int = 0,
1099
+ baseline_changes: Optional[List[str]] = None,
1011
1100
  ) -> Dict[str, Any]:
1012
1101
  global _ACTIVE_CHILD, _INTERRUPTED_SIGNAL
1013
1102
  _INTERRUPTED_SIGNAL = None
@@ -1064,8 +1153,9 @@ def _run_codex_task(
1064
1153
  use_json = runtime_config.json_output
1065
1154
  reasoning_effort = _resolve_reasoning_effort(runtime_config, model)
1066
1155
  communicate_timeout_s = _resolve_communicate_timeout_seconds(runtime_config)
1067
- prompt = _build_instruction(instruction, supplemental_guidance)
1068
- baseline_changes = summarize_git_changes(repo)
1156
+ effective_supplemental_guidance = _augment_supplemental_guidance(supplemental_guidance)
1157
+ prompt = _build_instruction(instruction, effective_supplemental_guidance)
1158
+ baseline_snapshot = list(baseline_changes) if baseline_changes is not None else summarize_git_changes(repo)
1069
1159
 
1070
1160
  with tempfile.TemporaryDirectory(prefix="pushpals-codex-") as tmp_dir:
1071
1161
  last_message_path = Path(tmp_dir) / "codex-last-message.txt"
@@ -1376,6 +1466,37 @@ def _run_codex_task(
1376
1466
  log_git_status(repo, log)
1377
1467
 
1378
1468
  if command_policy_rejection_loop:
1469
+ if wrapper_recovery_attempt < 1:
1470
+ recovery_guidance = _build_wrapper_recovery_guidance(rejected_shell_wrappers)
1471
+ if recovery_guidance:
1472
+ log.warning(
1473
+ "Codex hit a shell-wrapper rejection loop; retrying once with direct-command recovery guidance."
1474
+ )
1475
+ retry_result = _run_codex_task(
1476
+ repo,
1477
+ instruction,
1478
+ [*effective_supplemental_guidance, recovery_guidance],
1479
+ wrapper_recovery_attempt=wrapper_recovery_attempt + 1,
1480
+ baseline_changes=baseline_snapshot,
1481
+ )
1482
+ retry_result["usage"] = _merge_usage_records(usage, retry_result.get("usage"))
1483
+ if retry_result.get("ok"):
1484
+ recovered_stdout = str(retry_result.get("stdout") or "").strip()
1485
+ retry_result["stdout"] = _truncate(
1486
+ (
1487
+ "Recovered after the first Codex attempt hit command-router shell-wrapper rejections.\n\n"
1488
+ f"{recovered_stdout}"
1489
+ ).strip()
1490
+ )
1491
+ else:
1492
+ retry_stderr = str(retry_result.get("stderr") or "").strip()
1493
+ retry_result["stderr"] = _truncate(
1494
+ (
1495
+ "The first Codex attempt hit command-router shell-wrapper rejections and was retried once with direct-command recovery guidance.\n\n"
1496
+ f"{retry_stderr}"
1497
+ ).strip()
1498
+ )
1499
+ return retry_result
1379
1500
  command_lines = (
1380
1501
  "\n".join(f"- {command}" for command in rejected_shell_wrappers[:6])
1381
1502
  if rejected_shell_wrappers
@@ -1460,7 +1581,7 @@ def _run_codex_task(
1460
1581
  }
1461
1582
 
1462
1583
  changed_paths = summarize_git_changes(repo)
1463
- delta = [p for p in changed_paths if p not in baseline_changes]
1584
+ delta = [p for p in changed_paths if p not in baseline_snapshot]
1464
1585
  effective = delta if delta else changed_paths
1465
1586
  stdout_parts: List[str] = []
1466
1587
  if last_message:
@@ -13,6 +13,7 @@ for path in (_HERE, _SHARED):
13
13
  from executor_base import SettingsResolver, config_dir_for_runtime_config, runtime_config
14
14
  from openai_codex_executor import (
15
15
  OpenAICodexRuntimeConfig,
16
+ _augment_supplemental_guidance,
16
17
  _resolve_reasoning_effort,
17
18
  _build_instruction,
18
19
  _collect_disallowed_shell_wrapper_rejections,
@@ -20,6 +21,7 @@ from openai_codex_executor import (
20
21
  _extract_usage_counts,
21
22
  _load_prompt_template,
22
23
  _repo_root_for_prompt_loading,
24
+ _unwrap_shell_wrapper_command,
23
25
  _usage_from_trace_or_estimate,
24
26
  )
25
27
 
@@ -200,10 +202,35 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
200
202
  def test_collects_disallowed_shell_wrapper_rejections(self) -> None:
201
203
  commands = _collect_disallowed_shell_wrapper_rejections(
202
204
  "error=exec_command failed for `/bin/bash -lc pwd`: CreateProcess { message: \"Rejected\" }",
205
+ "error=exec_command failed for `/bin/bash -c \"git status --porcelain\"`: Rejected",
203
206
  "error=exec_command failed for `sh -lc \"git diff\"`: Rejected",
204
207
  "error=exec_command failed for `pwd`: Rejected",
205
208
  )
206
- self.assertEqual(commands, ["/bin/bash -lc pwd", 'sh -lc "git diff"'])
209
+ self.assertEqual(
210
+ commands,
211
+ ["/bin/bash -lc pwd", '/bin/bash -c "git status --porcelain"', 'sh -lc "git diff"'],
212
+ )
213
+
214
+ def test_unwraps_disallowed_shell_wrapper_commands_to_direct_commands(self) -> None:
215
+ self.assertEqual(
216
+ _unwrap_shell_wrapper_command("/bin/bash -lc 'git diff --name-only'"),
217
+ "git diff --name-only",
218
+ )
219
+ self.assertEqual(
220
+ _unwrap_shell_wrapper_command('cmd /c dir /b'),
221
+ "dir /b",
222
+ )
223
+ self.assertEqual(
224
+ _unwrap_shell_wrapper_command('pwsh -Command "Get-ChildItem src"'),
225
+ "Get-ChildItem src",
226
+ )
227
+
228
+ def test_augments_guidance_with_direct_command_policy_once(self) -> None:
229
+ guidance = _augment_supplemental_guidance(["Run bun test tests/example.test.ts"])
230
+ self.assertGreaterEqual(len(guidance), 2)
231
+ self.assertIn("direct commands only", guidance[0].lower())
232
+ guidance_again = _augment_supplemental_guidance(guidance)
233
+ self.assertEqual(guidance_again, guidance)
207
234
 
208
235
  def test_usage_falls_back_to_estimate_when_trace_has_no_usage(self) -> None:
209
236
  usage = _usage_from_trace_or_estimate({}, "abc" * 30, "done", model="gpt-5.4")
@@ -45,6 +45,7 @@
45
45
  "test:prompt-policy": "bun test tests/prompt-policy.enforcement.test.ts",
46
46
  "test:cli:integration": "bun test tests/cli.invocation-logging.test.ts tests/cli.runtime-bootstrap.test.ts tests/client.runtime-bootstrap.test.ts tests/shared.client-preflight.test.ts",
47
47
  "test:cli:e2e": "bun test ./tests/integration/cli.e2e.ts",
48
+ "test:workerpals:e2e": "bun test ./tests/integration/workerpals.control-plane.e2e.ts",
48
49
  "test:start:e2e": "bun test ./tests/integration/start.e2e.ts",
49
50
  "test:root": "bun test tests",
50
51
  "test:protocol": "bun run tests/protocol.integration.ts",