From 0c2b9f056adbb11097fd2de2b3e2a59f49a7f144 Mon Sep 17 00:00:00 2001 From: delta-cloud-208e Date: Sun, 26 Apr 2026 07:45:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(updater):=20SEA-aware=20install=20detection?= =?UTF-8?q?=20=E2=80=94=20recognise=20bin/claude.exe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After upstream switched to SEA (Single Executable Application) layout in v2.1.114+, there is no cli.js anymore — only bin/claude.exe. Before this fix: - find_cli_js() searched only for cli.js → returned None on SEA installs - get_installed_version() returned (None, None) → "Claude Code: not installed" - is_patched() returned (False, [3 markers]) → false-negative even after successful patch - cmd_check showed "not installed" right after a successful update - cmd_update would loop reinstalling because patch markers seemed missing This contributed to a user-facing incident where /model picker showed only built-in models even though CLAUDE_CUSTOM_MODELS was set in settings.json: the binary update path was triggering uninstall+reinstall cycles instead of being recognised as already-patched. Changes: - New find_claude_artifact() finds either cli.js or bin/claude.exe, including the deeply-nested layout npm uses for SEA wrapper packages - find_all_cli_js() returns both legacy and SEA artifacts - is_patched() auto-detects layout: text scan for cli.js, bytes scan for claude.exe (markers: /*ae1_models_filter_patched*/, /*bypass_permissions_prompt*/) - get_installed_version() reads package.json next to bin/ for SEA - uclaude_install.sh adds binary marker verification at end of install so users immediately see if the SEA payload was patched Tests: new tests/test_sea_detect.py covers all 4 detector functions against fake SEA install layouts (including nested form). All 20 tests pass. Co-Authored-By: Claude Opus 4.7 --- claude/tests/test_sea_detect.py | 167 ++++++++++++++++++++++++ claude/uclaude_install.sh | 34 +++++ claude/uclaude_updater.py | 218 ++++++++++++++++++++++++-------- 3 files changed, 367 insertions(+), 52 deletions(-) create mode 100644 claude/tests/test_sea_detect.py diff --git a/claude/tests/test_sea_detect.py b/claude/tests/test_sea_detect.py new file mode 100644 index 0000000..a8a5f34 --- /dev/null +++ b/claude/tests/test_sea_detect.py @@ -0,0 +1,167 @@ +"""Regression tests for SEA-aware detection in uclaude_updater.py. + +Background: before this fix, find_cli_js() searched ONLY for `cli.js` +files. After upstream switched to SEA layout (v2.1.114+), `cli.js` no +longer exists — there's only `bin/claude.exe`. As a result: + - find_cli_js() returned None + - get_installed_version() returned (None, None) + - is_patched() returned (False, [3 markers]) — false negative + - cmd_check showed "Claude Code: not installed" right after install + +These tests pin down the SEA-aware behaviour so it cannot regress. +""" +from __future__ import annotations + +import importlib.util +import json +import os +import sys +from pathlib import Path +from unittest.mock import patch as mock_patch + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location( + "uclaude_updater", ROOT / "uclaude_updater.py" +) +uu = importlib.util.module_from_spec(spec) +sys.modules["uclaude_updater"] = uu +spec.loader.exec_module(uu) + + +def _make_sea_install(npm_root: Path, version: str = "2.1.119", + patched: bool = True, nested: bool = False) -> Path: + """Create a fake SEA install layout under npm_root. + + nested=True → also nest a copy under + npm_root/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code/ + matching how npm SEA wrapper packages get unpacked. + """ + pkg = npm_root / "@anthropic-ai" / "claude-code" + if nested: + pkg = pkg / "node_modules" / "@anthropic-ai" / "claude-code" + bin_dir = pkg / "bin" + bin_dir.mkdir(parents=True) + payload = b"\x7fELF" + (b"X" * 4096) + if patched: + payload += b"/*ae1_models_filter_patched*/" + (b"Y" * 1024) + payload += b"/*bypass_permissions_prompt*/" + (b"Z" * 512) + binary = bin_dir / "claude.exe" + binary.write_bytes(payload) + os.chmod(binary, 0o755) + (pkg / "cli-wrapper.cjs").write_text("// wrapper\n") + (pkg / "package.json").write_text(json.dumps({"version": version})) + return binary + + +def test_find_claude_artifact_finds_sea_binary(tmp_path): + """SEA install: bin/claude.exe must be discovered when no cli.js.""" + npm_root = tmp_path / "npm_global" + npm_root.mkdir() + binary = _make_sea_install(npm_root) + with mock_patch("subprocess.run") as mrun: + mrun.return_value = type("R", (), { + "returncode": 0, "stdout": str(npm_root), "stderr": "", + })() + with mock_patch("os.path.isfile", side_effect=lambda p: Path(p).is_file()): + with mock_patch.object(uu, "IS_WINDOWS", False): + got = uu.find_claude_artifact() + assert got is not None + assert Path(got).name == "claude.exe" + + +def test_find_claude_artifact_finds_nested_sea(tmp_path): + """Nested layout: @anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code/bin/claude.exe""" + npm_root = tmp_path / "npm_global" + npm_root.mkdir() + binary = _make_sea_install(npm_root, nested=True) + with mock_patch("subprocess.run") as mrun: + # which claude → realpath returns the nested binary + def _run(cmd, **kw): + class R: pass + r = R() + r.returncode = 0 + r.stderr = "" + if cmd[:1] == ["which"]: + r.stdout = str(binary) + else: + r.stdout = str(npm_root) + return r + mrun.side_effect = _run + with mock_patch.object(uu, "IS_WINDOWS", False): + got = uu.find_claude_artifact() + assert got is not None + assert Path(got).resolve() == binary.resolve() + + +def test_is_patched_sea_recognises_marker_in_binary(tmp_path): + """SEA: is_patched scans binary as bytes, not text.""" + npm_root = tmp_path / "npm" + npm_root.mkdir() + binary = _make_sea_install(npm_root, patched=True) + patched, missing = uu.is_patched(str(binary)) + assert patched is True + assert missing == [] + + +def test_is_patched_sea_unpatched_returns_false(tmp_path): + """SEA without ae1/bypass markers must report not patched.""" + npm_root = tmp_path / "npm" + npm_root.mkdir() + binary = _make_sea_install(npm_root, patched=False) + patched, missing = uu.is_patched(str(binary)) + assert patched is False + # Both SEA markers should be reported missing + assert any("ae1" in m for m in missing) + assert any("bypass" in m for m in missing) + + +def test_is_patched_legacy_cli_js_still_works(tmp_path): + """Legacy cli.js path: text-based scan unchanged.""" + cli_js = tmp_path / "cli.js" + cli_js.write_text( + "// __CLAUDE_SETTINGS__\n" + "function x(){return!!(/*bypass_permissions_prompt*/false)}\n" + "/* root check removed by patcher */\n" + ) + patched, missing = uu.is_patched(str(cli_js)) + assert patched is True + assert missing == [] + + +def test_is_patched_returns_false_on_missing_file(): + patched, missing = uu.is_patched("/nonexistent/path") + assert patched is False + assert len(missing) > 0 + + +def test_get_installed_version_sea_reads_package_json(tmp_path): + """SEA: version comes from package.json next to bin/.""" + npm_root = tmp_path / "npm" + npm_root.mkdir() + binary = _make_sea_install(npm_root, version="2.1.119") + with mock_patch.object(uu, "find_claude_artifact", return_value=str(binary)): + v, p = uu.get_installed_version() + assert v == "2.1.119" + assert Path(p).name == "claude.exe" + + +def test_get_installed_version_returns_none_when_missing(): + with mock_patch.object(uu, "find_claude_artifact", return_value=None): + v, p = uu.get_installed_version() + assert v is None + assert p is None + + +def test_find_all_cli_js_returns_sea_artifact(tmp_path): + """Multi-install detector must also surface SEA binaries.""" + npm_root = tmp_path / "npm" + npm_root.mkdir() + binary = _make_sea_install(npm_root) + with mock_patch("subprocess.run") as mrun: + mrun.return_value = type("R", (), { + "returncode": 0, "stdout": str(npm_root), "stderr": "", + })() + with mock_patch.object(uu, "IS_WINDOWS", False): + paths = uu.find_all_cli_js() + sea_paths = [p for p in paths if p.endswith("claude.exe")] + assert len(sea_paths) >= 1 diff --git a/claude/uclaude_install.sh b/claude/uclaude_install.sh index f5d0bfc..44a1dda 100755 --- a/claude/uclaude_install.sh +++ b/claude/uclaude_install.sh @@ -193,6 +193,40 @@ else echo " (no $SETTINGS to verify — patcher may not have run)" fi +# Verify the installed binary itself carries patcher markers — catches the +# case where patcher.config.json + settings.json are correct but the binary +# wasn't actually patched (e.g. npm overwrote it after install). +echo "" +echo " Binary patch verification:" +CLAUDE_BIN="$(readlink -f "$(command -v claude 2>/dev/null)" 2>/dev/null || true)" +if [ -n "$CLAUDE_BIN" ] && [ -f "$CLAUDE_BIN" ]; then + case "$(basename "$CLAUDE_BIN")" in + cli.js) + if grep -q '__CLAUDE_SETTINGS__\|/\*bypass_permissions_prompt\*/' "$CLAUDE_BIN" 2>/dev/null; then + echo " cli.js: OK (patcher markers present)" + else + echo " cli.js: ⚠ NOT patched ($CLAUDE_BIN — re-run uclaude_updater.py --force)" + fi + ;; + claude.exe|claude) + # SEA binary — grep -a treats binary as text + sea_ok=true + grep -aq '/\*ae1_models_filter_patched' "$CLAUDE_BIN" 2>/dev/null || sea_ok=false + grep -aq '/\*bypass_permissions_prompt' "$CLAUDE_BIN" 2>/dev/null || sea_ok=false + if $sea_ok; then + echo " SEA binary: OK (ae1_models_filter + bypass_permissions markers)" + else + echo " SEA binary: ⚠ NOT fully patched ($CLAUDE_BIN — re-run uclaude_updater.py --force)" + fi + ;; + *) + echo " (unknown artifact: $CLAUDE_BIN)" + ;; + esac +else + echo " (no claude binary on PATH — check installation)" +fi + echo "" echo " Run claude: claude # interactive" echo " Update later: cd $INSTALL_DIR && sudo bash claude/uclaude_update.sh" diff --git a/claude/uclaude_updater.py b/claude/uclaude_updater.py index 78bbb4e..b3f29d0 100755 --- a/claude/uclaude_updater.py +++ b/claude/uclaude_updater.py @@ -315,74 +315,130 @@ def ensure_claude_code(target_version=None): # Version detection # ============================================================ -def find_cli_js(): - """Find installed Claude Code cli.js path.""" - candidates = [] +def find_claude_artifact(): + """Find the installed Claude Code primary artifact path. + + Returns the path to either: + - cli.js (legacy ≤2.1.113 installs) + - bin/claude.exe (SEA installs ≥2.1.114 — native binary) + + Prefers SEA layout when present. Returns None if nothing found. + + Note: SEA installs may end up nested as: + /@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code/bin/claude.exe + because npm resolves the platform-specific dep that way. We resolve + `which claude` → realpath() to find the actual binary regardless of + how deep the nesting is. + """ + # 1. Resolve via which claude → realpath (handles arbitrary nesting) + try: + result = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + real = os.path.realpath(result.stdout.strip()) + if os.path.basename(real) in ("cli.js", "claude.exe", "claude"): + return real + except Exception: + pass + + # 2. Static well-known paths — try BOTH cli.js and SEA layouts + js_candidates = [] + sea_candidates = [] if IS_WINDOWS: for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"): base = os.environ.get(env_key, "") if base: - candidates.append(os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code", "cli.js")) + pkg = os.path.join(base, "npm", "node_modules", "@anthropic-ai", "claude-code") + js_candidates.append(os.path.join(pkg, "cli.js")) + sea_candidates.append(os.path.join(pkg, "bin", "claude.exe")) else: - candidates = [ - "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js", - "/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js", - "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js", - ] + for prefix in ("/usr/lib", "/usr/local/lib", "/opt/homebrew/lib"): + pkg = os.path.join(prefix, "node_modules", "@anthropic-ai", "claude-code") + js_candidates.append(os.path.join(pkg, "cli.js")) + sea_candidates.append(os.path.join(pkg, "bin", "claude.exe")) + # Nested layout (npm install of SEA wrapper package) + nested_pkg = os.path.join(pkg, "node_modules", "@anthropic-ai", "claude-code") + js_candidates.append(os.path.join(nested_pkg, "cli.js")) + sea_candidates.append(os.path.join(nested_pkg, "bin", "claude.exe")) - # Prepend npm root -g result (most reliable, works across all Node install methods) + # 3. npm root -g try: - result = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10) - if result.returncode == 0: - npm_global = result.stdout.strip() - candidates.insert(0, os.path.join(npm_global, "@anthropic-ai", "claude-code", "cli.js")) + r = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10) + if r.returncode == 0: + npm_global = r.stdout.strip() + pkg = os.path.join(npm_global, "@anthropic-ai", "claude-code") + js_candidates.insert(0, os.path.join(pkg, "cli.js")) + sea_candidates.insert(0, os.path.join(pkg, "bin", "claude.exe")) + nested_pkg = os.path.join(pkg, "node_modules", "@anthropic-ai", "claude-code") + js_candidates.insert(0, os.path.join(nested_pkg, "cli.js")) + sea_candidates.insert(0, os.path.join(nested_pkg, "bin", "claude.exe")) except Exception: pass - for path in candidates: + # SEA preferred (newer install layout) + for path in sea_candidates: + if os.path.isfile(path): + return path + for path in js_candidates: if os.path.isfile(path): return path return None +def find_cli_js(): + """Backward-compat alias. Returns artifact path (cli.js OR claude.exe).""" + return find_claude_artifact() + + def find_all_cli_js(): - """Find ALL installed Claude Code cli.js paths (for multi-install patching).""" + """Find ALL installed Claude Code artifact paths (cli.js OR claude.exe). + + Returns list of paths to cli.js (legacy) AND/OR bin/claude.exe (SEA), + across multiple install locations (npm global, /usr/lib, NVM, nested). + """ candidates = set() + def _add_pkg(pkg): + """Register both legacy and SEA layouts under one package root.""" + candidates.add(os.path.join(pkg, "cli.js")) + candidates.add(os.path.join(pkg, "bin", "claude.exe")) + # Nested install (npm SEA wrapper) + nested = os.path.join(pkg, "node_modules", "@anthropic-ai", "claude-code") + candidates.add(os.path.join(nested, "cli.js")) + candidates.add(os.path.join(nested, "bin", "claude.exe")) + if IS_WINDOWS: for env_key in ("APPDATA", "LOCALAPPDATA", "PROGRAMFILES"): base = os.environ.get(env_key, "") if base: - candidates.add(os.path.join(base, "npm", "node_modules", - "@anthropic-ai", "claude-code", "cli.js")) + _add_pkg(os.path.join(base, "npm", "node_modules", + "@anthropic-ai", "claude-code")) else: # Static well-known paths for prefix in ("/usr/lib", "/usr/local/lib", "/opt/homebrew/lib"): - candidates.add(os.path.join(prefix, "node_modules", - "@anthropic-ai", "claude-code", "cli.js")) + _add_pkg(os.path.join(prefix, "node_modules", + "@anthropic-ai", "claude-code")) # npm root -g (primary install path) try: r = subprocess.run(["npm", "root", "-g"], capture_output=True, text=True, timeout=10) if r.returncode == 0: - candidates.add(os.path.join(r.stdout.strip(), - "@anthropic-ai", "claude-code", "cli.js")) + _add_pkg(os.path.join(r.stdout.strip(), + "@anthropic-ai", "claude-code")) except Exception: pass - # Resolve `which claude` → follow symlinks → find cli.js + # Resolve `which claude` → follow symlinks → find artifact try: r = subprocess.run(["which", "claude"], capture_output=True, text=True, timeout=5) if r.returncode == 0: claude_bin = os.path.realpath(r.stdout.strip()) - if os.path.basename(claude_bin) == "cli.js": - # which claude resolves directly to cli.js + bn = os.path.basename(claude_bin) + if bn in ("cli.js", "claude.exe", "claude"): candidates.add(claude_bin) else: # which claude points to .bin/claude wrapper - # cli.js is at node_modules/@anthropic-ai/claude-code/cli.js nm = os.path.dirname(os.path.dirname(claude_bin)) # node_modules/ - candidates.add(os.path.join(nm, "@anthropic-ai", "claude-code", "cli.js")) + _add_pkg(os.path.join(nm, "@anthropic-ai", "claude-code")) except Exception: pass @@ -395,8 +451,8 @@ def find_all_cli_js(): versions_dir = os.path.join(nvm_base, "versions", "node") if os.path.isdir(versions_dir): for node_ver in os.listdir(versions_dir): - candidates.add(os.path.join(versions_dir, node_ver, "lib", - "node_modules", "@anthropic-ai", "claude-code", "cli.js")) + _add_pkg(os.path.join(versions_dir, node_ver, "lib", + "node_modules", "@anthropic-ai", "claude-code")) return [p for p in candidates if os.path.isfile(p)] @@ -404,25 +460,58 @@ def find_all_cli_js(): def get_installed_version(): """Get currently installed Claude Code version. - Priority: cli.js bundle version > claude --version > package.json. - After patching, cli.js contains the real version while package.json - may still reflect the older npm-installed version. + Returns (version_str, artifact_path). + Handles both legacy cli.js layout and SEA binary layout. + + Priority for SEA: package.json → claude --version + Priority for cli.js: bundle scan → claude --version → package.json + + After patching, the cli.js bundle contains the real version while + package.json may still reflect the older npm-installed version. For + SEA we trust package.json because the binary is opaque. """ - cli_js = find_cli_js() - if not cli_js: + artifact = find_claude_artifact() + if not artifact: return None, None + bn = os.path.basename(artifact) + is_sea = bn in ("claude.exe", "claude") and not artifact.endswith(".js") + + if is_sea: + # 1. package.json sits two levels up from bin/claude.exe + pkg_json = os.path.join(os.path.dirname(os.path.dirname(artifact)), "package.json") + if os.path.isfile(pkg_json): + try: + with open(pkg_json) as f: + data = json.load(f) + v = data.get("version") + if v: + return v, artifact + except Exception: + pass + # 2. Fall back to claude --version + try: + result = run_cmd( + ["claude", "--version"], + capture_output=True, text=True, timeout=10, + ) + m = re.search(r"(\d+\.\d+\.\d+)", result.stdout) + if m: + return m.group(1), artifact + except Exception: + pass + return None, artifact + + # --- Legacy cli.js path --- # 1. Extract version from cli.js bundle itself (most accurate after patching) try: - with open(cli_js, "r", encoding="utf-8", errors="ignore") as f: - # Read first 100KB where version string usually lives + with open(artifact, "r", encoding="utf-8", errors="ignore") as f: head = f.read(100_000) - # Look for "// Version: x.y.z" comment or VERSION:"x.y.z" in the bundle m = re.search(r'//\s*Version:\s*(\d+\.\d+\.\d+)', head) if not m: m = re.search(r'(?:VERSION|version)\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', head) if m: - return m.group(1), cli_js + return m.group(1), artifact except Exception: pass @@ -434,21 +523,21 @@ def get_installed_version(): ) m = re.search(r"(\d+\.\d+\.\d+)", result.stdout) if m: - return m.group(1), cli_js + return m.group(1), artifact except Exception: pass # 3. Fallback: package.json (may be stale after cli.js replacement) - pkg_json = os.path.join(os.path.dirname(cli_js), "package.json") + pkg_json = os.path.join(os.path.dirname(artifact), "package.json") if os.path.isfile(pkg_json): try: - with open(pkg_json, "r") as f: + with open(pkg_json) as f: data = json.load(f) - return data.get("version"), cli_js + return data.get("version"), artifact except Exception: pass - return None, cli_js + return None, artifact def get_latest_version(): @@ -470,24 +559,49 @@ def ver_tuple(v): return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0) -# Markers left by the patcher in cli.js — if any is missing, cli.js is not patched -PATCH_MARKERS = [ +# Markers left by the patcher — if any is missing, the artifact is not patched. +# Legacy cli.js markers (text patches in JS bundle): +PATCH_MARKERS_JS = [ "__CLAUDE_SETTINGS__", "/*bypass_permissions_prompt*/", "/* root check removed by patcher */", ] +# SEA binary markers (byte-level patches inside SEA payload): +PATCH_MARKERS_SEA = [ + b"/*bypass_permissions_prompt", + b"/*ae1_models_filter_patched", +] -def is_patched(cli_js_path): - """Check if cli.js has patch markers. Returns (patched: bool, missing: list).""" - if not cli_js_path or not os.path.isfile(cli_js_path): - return False, PATCH_MARKERS[:] +def is_patched(artifact_path): + """Check if installed artifact (cli.js OR claude.exe) is patched. + + Returns (patched: bool, missing: list[str|bytes]). + Auto-detects layout from file basename. + """ + if not artifact_path or not os.path.isfile(artifact_path): + return False, PATCH_MARKERS_JS[:] + + bn = os.path.basename(artifact_path) + is_sea = bn in ("claude.exe", "claude") and not artifact_path.endswith(".js") + + if is_sea: + # SEA: scan as binary; markers are bytes + try: + with open(artifact_path, "rb") as f: + content = f.read() + except Exception: + return False, [m.decode() for m in PATCH_MARKERS_SEA] + missing = [m for m in PATCH_MARKERS_SEA if m not in content] + return len(missing) == 0, [m.decode() for m in missing] + + # Legacy cli.js text scan try: - with open(cli_js_path, "r", encoding="utf-8", errors="ignore") as f: + with open(artifact_path, "r", encoding="utf-8", errors="ignore") as f: content = f.read() except Exception: - return False, PATCH_MARKERS[:] - missing = [m for m in PATCH_MARKERS if m not in content] + return False, PATCH_MARKERS_JS[:] + missing = [m for m in PATCH_MARKERS_JS if m not in content] return len(missing) == 0, missing