Last active
April 3, 2026 04:34
-
-
Save Macmee/a9e117a7c1ebaadea24c171c1c834651 to your computer and use it in GitHub Desktop.
Enable auto mode in Claude Code with LiteLLM or any ANTHROPIC_BASE_URL proxy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| patch-claude-auto-mode.py | |
| Enables auto mode in Claude Code for plans that don't natively include it. | |
| Two patches are applied: | |
| 1) Beta header: Claude Code sends an "afk-mode" beta header with API | |
| requests when auto mode is active. The Anthropic API validates this | |
| against the user's plan and returns HTTP 400 if the plan doesn't | |
| include auto mode. We flip the `!` in `!arr.includes(hdr)` to a | |
| space, inverting the condition so the header is never pushed. | |
| 2) Client-side gate: GrowthBook returns enabled="disabled" for plans | |
| without auto mode. Gate checks compare enabledState against "disabled" | |
| and block auto mode. We change the comparison literals to "disablez" | |
| so they never match. The parser and default value are left intact. | |
| Both patches are needed: (1) alone leaves the client gate blocking, | |
| (2) alone leaves the server rejecting the request. | |
| Usage: | |
| ./patch-claude-auto-mode.py # patch current claude binary | |
| ./patch-claude-auto-mode.py --verify # check if already patched | |
| ./patch-claude-auto-mode.py --revert # restore from backup | |
| ./patch-claude-auto-mode.py --debug # show all targets | |
| After patching, launch claude with: | |
| claude --enable-auto-mode --permission-mode auto | |
| """ | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| GREEN = "\033[0;32m" | |
| YELLOW = "\033[0;33m" | |
| CYAN = "\033[0;36m" | |
| RED = "\033[0;31m" | |
| NC = "\033[0m" | |
| def info(msg): print(f"{GREEN}[ok]{NC} {msg}") | |
| def warn(msg): print(f"{YELLOW}[warn]{NC} {msg}") | |
| def note(msg): print(f"{CYAN}[info]{NC} {msg}") | |
| def fail(msg): print(f"{RED}[error]{NC} {msg}") | |
| def find_claude_binary(): | |
| try: | |
| which = subprocess.run(["which", "claude"], capture_output=True, text=True) | |
| if which.returncode != 0: | |
| return None | |
| path = which.stdout.strip() | |
| return os.path.realpath(path) | |
| except Exception: | |
| return None | |
| def get_claude_version(binary_path): | |
| try: | |
| result = subprocess.run([binary_path, "--version"], capture_output=True, text=True, timeout=5) | |
| return result.stdout.strip() | |
| except Exception: | |
| return "unknown" | |
| # ============================================================================ | |
| # Patch 1: Beta header push prevention. | |
| # | |
| # The compiled code has: !ARRAY.includes(VAR))ARRAY.push(VAR) | |
| # where VAR is the mangled name for AFK_MODE_BETA_HEADER ("afk-mode-..."). | |
| # | |
| # We flip the `!` to ` ` (space). This turns: | |
| # !arr.includes(hdr) -> true (header absent) -> push fires | |
| # into: | |
| # arr.includes(hdr) -> false (header absent) -> push skipped | |
| # | |
| # The variable name is extracted dynamically from the const definition | |
| # VARNAME="afk-mode-YYYY-MM-DD", so this is version-agnostic. | |
| # ============================================================================ | |
| import re | |
| def find_beta_header_targets(data): | |
| unpatched = [] | |
| already = [] | |
| # Find each const definition: VARNAME="afk-mode-YYYY-MM-DD" | |
| for m in re.finditer(rb'(\w+)="afk-mode-\d{4}-\d{2}-\d{2}"', data): | |
| varname = re.escape(m.group(1)) | |
| # Pattern: !ARR.includes(VAR))ARR.push(VAR) | |
| pat = (rb'(!)(\w+\.includes\(' + varname + | |
| rb'\))\2*\w*\.push\(' + varname + rb'\)') | |
| # Simpler: just find !WORD.includes(VAR)) followed by push(VAR) | |
| pat = (rb'!(\w+)\.includes\(' + varname + | |
| rb'\)\)\1\.push\(' + varname + rb'\)') | |
| for m2 in re.finditer(pat, data): | |
| bang_pos = m2.start() | |
| if data[bang_pos:bang_pos + 1] == b"!": | |
| unpatched.append(bang_pos) | |
| # (already-patched would be a space at that position) | |
| # Detect already-patched: space where ! was | |
| pat_done = (rb' (\w+)\.includes\(' + varname + | |
| rb'\)\)\1\.push\(' + varname + rb'\)') | |
| for m3 in re.finditer(pat_done, data): | |
| already.append(m3.start()) | |
| return unpatched, already | |
| # ============================================================================ | |
| # Patch 2: Client-side gate checks ("disabled" -> "disablez" in comparisons) | |
| # ============================================================================ | |
| def _find_exclusion_zones(data): | |
| """Byte ranges NOT to patch: parser functions and default assignments.""" | |
| zones = [] | |
| disabled_str = b'"disabled"' | |
| opt_in_str = b'"opt-in"' | |
| enabled_str = b'"enabled"' | |
| # Parser functions (contain all three strings within ~120 bytes) | |
| pos = 0 | |
| while True: | |
| pos = data.find(opt_in_str, pos) | |
| if pos == -1: break | |
| window_start = max(0, pos - 120) | |
| window = data[window_start:pos + len(opt_in_str)] | |
| if enabled_str in window and disabled_str in window: | |
| zones.append((max(0, pos - 150), pos + 50)) | |
| pos += 1 | |
| # Default variable assignments: ="disabled" (not ==="disabled") | |
| pos = 0 | |
| while True: | |
| pos = data.find(disabled_str, pos) | |
| if pos == -1: break | |
| if pos >= 1: | |
| before = data[max(0, pos - 3):pos] | |
| if before.endswith(b"=") and not before.endswith(b"=="): | |
| zones.append((pos, pos + len(disabled_str))) | |
| pos += 1 | |
| return zones | |
| def _in_zone(pos, zones): | |
| for s, e in zones: | |
| if s <= pos <= e: | |
| return True | |
| return False | |
| def _is_patchable(data, pos): | |
| if pos == 0: return True | |
| before = data[pos - 1:pos] | |
| return before not in (b" ", b"`") | |
| def _scan(data, start, end, needle, patch_needle, results, patched, seen, zones): | |
| region = data[start:end] | |
| idx = 0 | |
| while True: | |
| idx_d = region.find(needle, idx) | |
| idx_p = region.find(patch_needle, idx) | |
| if idx_d == -1 and idx_p == -1: break | |
| if idx_d != -1 and (idx_p == -1 or idx_d <= idx_p): | |
| abs_pos = start + idx_d | |
| d_pos = abs_pos + 8 | |
| if (d_pos not in seen and data[d_pos:d_pos+1] == b"d" | |
| and not _in_zone(abs_pos, zones) | |
| and _is_patchable(data, abs_pos)): | |
| results.append(d_pos) | |
| seen.add(d_pos) | |
| idx = idx_d + 1 | |
| else: | |
| abs_pos = start + idx_p | |
| z_pos = abs_pos + 8 | |
| if (z_pos not in seen and not _in_zone(abs_pos, zones) | |
| and _is_patchable(data, abs_pos)): | |
| patched.append(z_pos) | |
| seen.add(z_pos) | |
| idx = idx_p + 1 | |
| def find_gate_check_targets(data): | |
| disabled_str = b'"disabled"' | |
| patched_str = b'"disablez"' | |
| unpatched, already = [], [] | |
| seen = set() | |
| zones = _find_exclusion_zones(data) | |
| # Strategy A: tengu_auto_mode_config anchor | |
| anchor = b"tengu_auto_mode_config" | |
| pos = 0 | |
| while True: | |
| pos = data.find(anchor, pos) | |
| if pos == -1: break | |
| _scan(data, max(0, pos-4000), min(len(data), pos+2000), | |
| disabled_str, patched_str, unpatched, already, seen, zones) | |
| pos += 1 | |
| # Strategy B: setAutoModeCircuitBroken anchor | |
| anchor2 = b"setAutoModeCircuitBroken" | |
| pos = 0 | |
| while True: | |
| pos = data.find(anchor2, pos) | |
| if pos == -1: break | |
| chunk = data[pos:pos+600] | |
| if disabled_str in chunk or patched_str in chunk: | |
| _scan(data, pos, pos+600, disabled_str, patched_str, | |
| unpatched, already, seen, zones) | |
| pos += 1 | |
| # Strategy C: command("auto-mode") anchor | |
| anchor3 = b'command("auto-mode")' | |
| pos = 0 | |
| while True: | |
| pos = data.find(anchor3, pos) | |
| if pos == -1: break | |
| _scan(data, max(0, pos-200), min(len(data), pos+50), | |
| disabled_str, patched_str, unpatched, already, seen, zones) | |
| pos += 1 | |
| return unpatched, already | |
| # ============================================================================ | |
| # Commands | |
| # ============================================================================ | |
| def cmd_patch(binary_path): | |
| with open(binary_path, "rb") as f: | |
| data = bytearray(f.read()) | |
| beta_unp, beta_done = find_beta_header_targets(data) | |
| gate_unp, gate_done = find_gate_check_targets(data) | |
| n_beta = len(beta_unp) | |
| n_gate = len(gate_unp) | |
| n_beta_done = len(beta_done) | |
| n_gate_done = len(gate_done) | |
| if n_beta + n_gate + n_beta_done + n_gate_done == 0: | |
| fail("No patch targets found. Is this Claude Code v2.1.85+?") | |
| return 1 | |
| if n_beta + n_gate == 0: | |
| info(f"Already patched ({n_beta_done} headers + {n_gate_done} gate checks)") | |
| return 0 | |
| # Backup | |
| backup = binary_path + ".pre-automode-patch" | |
| if not os.path.exists(backup): | |
| shutil.copy2(binary_path, backup) | |
| note(f"Backup saved: {backup}") | |
| # Apply patches | |
| for p in beta_unp: | |
| data[p] = ord(" ") # '!' -> ' ' prevents beta header push | |
| for p in gate_unp: | |
| data[p] = ord("z") # 'd' -> 'z' in "disabled" -> "disablez" | |
| # Write | |
| tmp_fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(binary_path)) | |
| try: | |
| os.write(tmp_fd, data) | |
| os.close(tmp_fd) | |
| os.chmod(tmp_path, os.stat(binary_path).st_mode) | |
| os.rename(tmp_path, binary_path) | |
| except Exception: | |
| os.unlink(tmp_path) | |
| raise | |
| info(f"Patched {n_beta} beta headers (nulled) + {n_gate} gate checks") | |
| # Re-sign on macOS | |
| if sys.platform == "darwin": | |
| try: | |
| subprocess.run(["codesign", "--force", "--sign", "-", binary_path], | |
| capture_output=True, check=True) | |
| info("Re-signed binary (macOS ad-hoc signature)") | |
| except FileNotFoundError: | |
| warn("codesign not found; may be killed by macOS Gatekeeper") | |
| except subprocess.CalledProcessError as e: | |
| warn(f"codesign failed: {e.stderr.decode().strip()}") | |
| print() | |
| info("Launch with: claude --enable-auto-mode --permission-mode auto") | |
| print() | |
| note(f"To undo: {sys.argv[0]} --revert") | |
| note("Re-run after each Claude Code update.") | |
| return 0 | |
| def cmd_verify(binary_path): | |
| with open(binary_path, "rb") as f: | |
| data = f.read() | |
| beta_unp, beta_done = find_beta_header_targets(data) | |
| gate_unp, gate_done = find_gate_check_targets(data) | |
| n_unp = len(beta_unp) + len(gate_unp) | |
| n_done = len(beta_done) + len(gate_done) | |
| if n_unp == 0 and n_done == 0: | |
| fail("No patch targets found.") | |
| return 1 | |
| if n_unp == 0: | |
| info(f"Fully patched ({len(beta_done)} headers + {len(gate_done)} gate checks)") | |
| return 0 | |
| if n_done == 0: | |
| warn(f"Not patched ({len(beta_unp)} headers + {len(gate_unp)} gate checks)") | |
| else: | |
| warn(f"Partial: {n_done} done, {n_unp} remaining") | |
| return 1 | |
| def cmd_revert(binary_path): | |
| backup = binary_path + ".pre-automode-patch" | |
| if not os.path.exists(backup): | |
| fail(f"No backup found at {backup}") | |
| return 1 | |
| shutil.copy2(backup, binary_path) | |
| info("Restored from backup") | |
| return 0 | |
| def cmd_debug(binary_path): | |
| with open(binary_path, "rb") as f: | |
| data = f.read() | |
| print("=== Beta header push targets ===") | |
| beta_unp, beta_done = find_beta_header_targets(data) | |
| print(f" Unpatched: {len(beta_unp)} Already patched: {len(beta_done)}") | |
| for p in beta_unp: | |
| ctx = data[max(0,p-20):p+60].decode("utf-8", errors="replace") | |
| print(f" offset {p}: ...{ctx}...") | |
| print("\n=== Gate check targets ===") | |
| gate_unp, gate_done = find_gate_check_targets(data) | |
| print(f" Unpatched: {len(gate_unp)} Already patched: {len(gate_done)}") | |
| for p in sorted(gate_unp): | |
| ctx = data[max(0,p-40):p+20].decode("utf-8", errors="replace") | |
| print(f" offset {p}: ...{ctx}...") | |
| return 0 | |
| def main(): | |
| mode = "patch" | |
| if len(sys.argv) > 1: | |
| flag = sys.argv[1] | |
| if flag == "--verify": mode = "verify" | |
| elif flag == "--revert": mode = "revert" | |
| elif flag == "--debug": mode = "debug" | |
| elif flag in ("-h", "--help"): | |
| print(__doc__.strip()) | |
| return 0 | |
| else: | |
| fail(f"Unknown flag: {flag}") | |
| return 1 | |
| binary_path = find_claude_binary() | |
| if not binary_path: | |
| fail("Could not find claude binary. Is it installed?") | |
| return 1 | |
| note(f"Binary: {binary_path}") | |
| note(f"Version: {get_claude_version(binary_path)}") | |
| print() | |
| if mode == "verify": return cmd_verify(binary_path) | |
| elif mode == "revert": return cmd_revert(binary_path) | |
| elif mode == "debug": return cmd_debug(binary_path) | |
| else: return cmd_patch(binary_path) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment