|
96 | 96 | _VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"} |
97 | 97 | _VALID_REASONING_EFFORTS = {"low", "medium", "high", "xhigh"} |
98 | 98 | _MAX_WRAPPER_RECOVERY_ATTEMPTS = 2 |
| 99 | +_MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS = 1_200 |
| 100 | +_MAX_WRAPPER_BOOTSTRAP_TOTAL_CHARS = 5_000 |
99 | 101 |
|
100 | 102 |
|
101 | 103 | def _model_supports_xhigh_reasoning(model: str) -> bool: |
@@ -1108,6 +1110,177 @@ def _build_wrapper_recovery_guidance(rejected_commands: List[str], *, hard: bool |
1108 | 1110 | return "\n".join(guidance_lines) |
1109 | 1111 |
|
1110 | 1112 |
|
| 1113 | +def _truncate_wrapper_bootstrap_output(text: str) -> str: |
| 1114 | + value = str(text or "").replace("\r\n", "\n").strip() |
| 1115 | + if len(value) <= _MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS: |
| 1116 | + return value |
| 1117 | + return f"{value[:_MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS].rstrip()}\n...(truncated)" |
| 1118 | + |
| 1119 | + |
| 1120 | +def _resolve_repo_scoped_path(repo: str, raw_path: str) -> Optional[Path]: |
| 1121 | + candidate = str(raw_path or "").strip() |
| 1122 | + if not candidate: |
| 1123 | + return None |
| 1124 | + repo_root = Path(repo).resolve() |
| 1125 | + resolved = (repo_root / candidate).resolve() |
| 1126 | + try: |
| 1127 | + common = os.path.commonpath([str(repo_root), str(resolved)]) |
| 1128 | + except ValueError: |
| 1129 | + return None |
| 1130 | + if common != str(repo_root): |
| 1131 | + return None |
| 1132 | + return resolved |
| 1133 | + |
| 1134 | + |
| 1135 | +def _run_wrapper_bootstrap_command(repo: str, command: str) -> str: |
| 1136 | + normalized = _normalize_command_text(command) |
| 1137 | + if not normalized: |
| 1138 | + return "" |
| 1139 | + try: |
| 1140 | + args = shlex.split(normalized, posix=True) |
| 1141 | + except ValueError: |
| 1142 | + return "" |
| 1143 | + if not args: |
| 1144 | + return "" |
| 1145 | + program = str(args[0] or "").strip().lower() |
| 1146 | + if program == "pwd" and len(args) == 1: |
| 1147 | + return repo |
| 1148 | + if program == "ls": |
| 1149 | + target = Path(repo).resolve() |
| 1150 | + if len(args) == 2 and not str(args[1]).startswith("-"): |
| 1151 | + resolved = _resolve_repo_scoped_path(repo, str(args[1])) |
| 1152 | + if not resolved: |
| 1153 | + return "" |
| 1154 | + target = resolved |
| 1155 | + elif len(args) > 1: |
| 1156 | + return "" |
| 1157 | + if not target.exists(): |
| 1158 | + return f"{target.name or str(target)} (missing)" |
| 1159 | + if target.is_file(): |
| 1160 | + return target.name |
| 1161 | + entries = sorted(child.name for child in target.iterdir()) |
| 1162 | + return "\n".join(entries[:120]) |
| 1163 | + if program == "git" and len(args) >= 2: |
| 1164 | + safe_git_args: Optional[List[str]] = None |
| 1165 | + if args[1:] == ["branch", "--show-current"]: |
| 1166 | + safe_git_args = ["git", "--no-pager", "branch", "--show-current"] |
| 1167 | + elif args[1:] == ["status", "--porcelain"]: |
| 1168 | + safe_git_args = ["git", "--no-pager", "status", "--porcelain"] |
| 1169 | + elif len(args) >= 3 and args[1] == "diff": |
| 1170 | + diff_args = list(args[2:]) |
| 1171 | + sanitized_paths: List[str] = [] |
| 1172 | + if diff_args == ["--name-only"]: |
| 1173 | + safe_git_args = [ |
| 1174 | + "git", |
| 1175 | + "--no-pager", |
| 1176 | + "diff", |
| 1177 | + "--no-ext-diff", |
| 1178 | + "--no-textconv", |
| 1179 | + "--name-only", |
| 1180 | + ] |
| 1181 | + elif len(diff_args) >= 2 and diff_args[0] == "--name-only" and diff_args[1] == "--": |
| 1182 | + for raw_path in diff_args[2:]: |
| 1183 | + resolved = _resolve_repo_scoped_path(repo, str(raw_path)) |
| 1184 | + if not resolved: |
| 1185 | + return "" |
| 1186 | + sanitized_paths.append(os.path.relpath(str(resolved), repo)) |
| 1187 | + safe_git_args = [ |
| 1188 | + "git", |
| 1189 | + "--no-pager", |
| 1190 | + "diff", |
| 1191 | + "--no-ext-diff", |
| 1192 | + "--no-textconv", |
| 1193 | + "--name-only", |
| 1194 | + "--", |
| 1195 | + *sanitized_paths, |
| 1196 | + ] |
| 1197 | + elif diff_args and diff_args[0] == "--": |
| 1198 | + for raw_path in diff_args[1:]: |
| 1199 | + resolved = _resolve_repo_scoped_path(repo, str(raw_path)) |
| 1200 | + if not resolved: |
| 1201 | + return "" |
| 1202 | + sanitized_paths.append(os.path.relpath(str(resolved), repo)) |
| 1203 | + safe_git_args = [ |
| 1204 | + "git", |
| 1205 | + "--no-pager", |
| 1206 | + "diff", |
| 1207 | + "--no-ext-diff", |
| 1208 | + "--no-textconv", |
| 1209 | + "--", |
| 1210 | + *sanitized_paths, |
| 1211 | + ] |
| 1212 | + if not safe_git_args: |
| 1213 | + return "" |
| 1214 | + proc = subprocess.run( |
| 1215 | + safe_git_args, |
| 1216 | + cwd=repo, |
| 1217 | + capture_output=True, |
| 1218 | + text=True, |
| 1219 | + timeout=15, |
| 1220 | + check=False, |
| 1221 | + ) |
| 1222 | + output = proc.stdout.strip() |
| 1223 | + if proc.returncode != 0: |
| 1224 | + detail = proc.stderr.strip() or output |
| 1225 | + return f"(command failed: {detail})" if detail else "(command failed)" |
| 1226 | + return output |
| 1227 | + if program == "cat" and len(args) == 2: |
| 1228 | + resolved = _resolve_repo_scoped_path(repo, str(args[1])) |
| 1229 | + if not resolved or not resolved.is_file(): |
| 1230 | + return "" |
| 1231 | + return resolved.read_text(encoding="utf-8", errors="replace") |
| 1232 | + if program == "sed" and len(args) == 4 and args[1] == "-n": |
| 1233 | + match = re.fullmatch(r"(\d+),(\d+)p", str(args[2] or "").strip()) |
| 1234 | + if not match: |
| 1235 | + return "" |
| 1236 | + start = max(1, int(match.group(1))) |
| 1237 | + end = max(start, int(match.group(2))) |
| 1238 | + resolved = _resolve_repo_scoped_path(repo, str(args[3])) |
| 1239 | + if not resolved or not resolved.is_file(): |
| 1240 | + return "" |
| 1241 | + lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines() |
| 1242 | + return "\n".join(lines[start - 1 : end]) |
| 1243 | + return "" |
| 1244 | + |
| 1245 | + |
| 1246 | +def _build_wrapper_bootstrap_context(repo: str, rejected_commands: List[str]) -> str: |
| 1247 | + blocks: List[str] = [] |
| 1248 | + total_chars = 0 |
| 1249 | + seen: set[str] = set() |
| 1250 | + for rejected in rejected_commands: |
| 1251 | + direct = _unwrap_shell_wrapper_command(rejected) |
| 1252 | + key = direct.lower() |
| 1253 | + if not direct or key in seen: |
| 1254 | + continue |
| 1255 | + seen.add(key) |
| 1256 | + output = _run_wrapper_bootstrap_command(repo, direct) |
| 1257 | + if not output: |
| 1258 | + continue |
| 1259 | + truncated = _truncate_wrapper_bootstrap_output(output) |
| 1260 | + block = ( |
| 1261 | + f"- Direct command: `{direct}`\n" |
| 1262 | + f" Rejected wrapper: `{rejected}`\n" |
| 1263 | + " Output:\n" |
| 1264 | + " ```text\n" |
| 1265 | + f"{truncated}\n" |
| 1266 | + " ```" |
| 1267 | + ) |
| 1268 | + if total_chars + len(block) > _MAX_WRAPPER_BOOTSTRAP_TOTAL_CHARS and blocks: |
| 1269 | + break |
| 1270 | + blocks.append(block) |
| 1271 | + total_chars += len(block) |
| 1272 | + if not blocks: |
| 1273 | + return "" |
| 1274 | + return "\n".join( |
| 1275 | + [ |
| 1276 | + "Direct command context bootstrap:", |
| 1277 | + "The backend already ran safe read-only direct replacements for some rejected wrapper commands.", |
| 1278 | + "Use these outputs as current repo context and do not rerun the wrapped variants.", |
| 1279 | + *blocks[:6], |
| 1280 | + ] |
| 1281 | + ) |
| 1282 | + |
| 1283 | + |
1111 | 1284 | def _merge_usage_records(first: Any, second: Any) -> Dict[str, Any]: |
1112 | 1285 | first_record = first if isinstance(first, dict) else {} |
1113 | 1286 | second_record = second if isinstance(second, dict) else {} |
@@ -1547,18 +1720,28 @@ def _drain_stderr() -> None: |
1547 | 1720 | hard=hard_recovery, |
1548 | 1721 | ) |
1549 | 1722 | if recovery_guidance: |
| 1723 | + bootstrap_context = ( |
| 1724 | + _build_wrapper_bootstrap_context(repo, rejected_shell_wrappers) |
| 1725 | + if hard_recovery |
| 1726 | + else "" |
| 1727 | + ) |
1550 | 1728 | log.warning( |
1551 | 1729 | "Codex hit a shell-wrapper rejection loop; retrying once with " |
1552 | 1730 | + ( |
1553 | 1731 | "strict no-wrapper recovery guidance." |
| 1732 | + + (" Added direct-command context bootstrap." if bootstrap_context else "") |
1554 | 1733 | if hard_recovery |
1555 | 1734 | else "direct-command recovery guidance." |
1556 | 1735 | ) |
1557 | 1736 | ) |
1558 | 1737 | retry_result = _run_codex_task( |
1559 | 1738 | repo, |
1560 | 1739 | instruction, |
1561 | | - [*effective_supplemental_guidance, recovery_guidance], |
| 1740 | + [ |
| 1741 | + *effective_supplemental_guidance, |
| 1742 | + *( [bootstrap_context] if bootstrap_context else [] ), |
| 1743 | + recovery_guidance, |
| 1744 | + ], |
1562 | 1745 | wrapper_recovery_attempt=wrapper_recovery_attempt + 1, |
1563 | 1746 | baseline_changes=baseline_snapshot, |
1564 | 1747 | ) |
|
0 commit comments