fix(updater): SEA-aware install detection — recognise bin/claude.exe

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 <noreply@anthropic.com>
This commit is contained in:
delta-cloud-208e
2026-04-26 07:45:35 +00:00
parent dd11b9784d
commit 0c2b9f056a
3 changed files with 367 additions and 52 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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:
<npm_root>/@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