From 3d4b371e178fc7fea1e4bf2453dbfd3f047ddbdb Mon Sep 17 00:00:00 2001 From: delta-cloud-208e Date: Fri, 24 Apr 2026 11:54:17 +0000 Subject: [PATCH] feat(updater): SEA install support for Claude Code 2.1.114+ (TDD + dual-critic APPROVE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds install_sea_release() to uclaude_updater.py — dispatches by release type from releases/v/, supporting both legacy cli.js and new SEA layout. Production-hardened (after 2 rounds of dual-critic FIX): - Pre-verify source sha256 BEFORE touching install_root (fail-fast) - Atomic copy via _atomic_copy_with_fsync: write to .new, fsync, rename, then fsync parent dir (POSIX durability) - .new cleanup on any exception (no orphan files) - fcntl.flock on /.uclaude-update.lock (concurrent run safe) - Backup existing → .bak. before overwrite - Post-install sha256 verify; rollback from backup on mismatch - Atomic symlink update (tmp_link + os.replace) cmd_update dispatches: - detect_release_type → "sea" / "cli_js" / None - "sea" → install_sea_release with /usr/lib/node_modules root + /usr/bin/claude symlink - "cli_js" → existing legacy install_cli_js (preserved) - None → fail with clear error Updated: - claude/uclaude_updater.py — +138 lines (install_sea_release + helpers) - claude/releases/index.json — latest=2.1.119, +v2.1.119 entry (sea_binary) Tests: 11/11 GREEN (claude/tests/test_sea_install.py — new file) Dual critic: gpt-5.4 + GLM 5.1 both APPROVE (round 3 final) Co-Authored-By: Claude Opus 4.7 --- claude/releases/index.json | 9 +- claude/tests/test_sea_install.py | 236 +++++++++++++++++++++++++++ claude/uclaude_updater.py | 266 ++++++++++++++++++++++++++++--- 3 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 claude/tests/test_sea_install.py diff --git a/claude/releases/index.json b/claude/releases/index.json index 3d26379..94819ce 100755 --- a/claude/releases/index.json +++ b/claude/releases/index.json @@ -1,6 +1,13 @@ { - "latest": "2.1.112", + "latest": "2.1.119", "releases": [ + { + "version": "2.1.119", + "date": "2026-04-24", + "patches": 9, + "status": "stable", + "install_type": "sea_binary" + }, { "version": "2.1.112", "date": "2026-04-17", diff --git a/claude/tests/test_sea_install.py b/claude/tests/test_sea_install.py new file mode 100644 index 0000000..0d27ba2 --- /dev/null +++ b/claude/tests/test_sea_install.py @@ -0,0 +1,236 @@ +"""TDD — uclaude_updater.py SEA install support. + +Verifies that install_sea_release(): +- Locates releases/v/sea/{claude, cli-wrapper.cjs, manifest.json} +- Installs via npm: ensures @anthropic-ai/claude-code@ + native pkg +- Replaces installed bin/claude.exe with patched binary (sha256 match) +- Replaces installed cli-wrapper.cjs with patched wrapper +- Updates /usr/bin/claude symlink to bin/claude.exe (npm convention) +- Verifies sha256 of installed binary matches manifest + +Why TDD: this is a high-risk function — it touches /usr/lib system paths. +Tests use a temp install_root to avoid touching the real system. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import shutil +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Load uclaude_updater as module (no package init at claude/) +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 _sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def _make_release_dir(root: Path, version: str, binary_bytes: bytes, + wrapper_text: str) -> Path: + """Build releases/v/sea/{claude, cli-wrapper.cjs, manifest.json}.""" + sea = root / "releases" / f"v{version}" / "sea" + sea.mkdir(parents=True) + (sea / "claude").write_bytes(binary_bytes) + os.chmod(sea / "claude", 0o755) + (sea / "cli-wrapper.cjs").write_text(wrapper_text) + manifest = { + "version": version, + "binary_sha256": _sha256(sea / "claude"), + "wrapper_sha256": _sha256(sea / "cli-wrapper.cjs"), + "binary_size": (sea / "claude").stat().st_size, + "wrapper_size": (sea / "cli-wrapper.cjs").stat().st_size, + "install_type": "sea_binary", + } + (sea / "manifest.json").write_text(json.dumps(manifest, indent=2)) + return sea + + +def _make_existing_install(install_root: Path, version: str = "2.1.100") -> None: + """Build /usr/lib/node_modules/@anthropic-ai/claude-code/* style structure.""" + pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" + bin_dir = pkg / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "claude.exe").write_bytes(b"\x7fELForigvOLD") + os.chmod(bin_dir / "claude.exe", 0o755) + (pkg / "cli-wrapper.cjs").write_text("// old wrapper\n") + (pkg / "package.json").write_text(json.dumps({"version": version})) + + +# ---------- detect_release_type ---------- + +def test_detect_release_type_finds_sea(tmp_path): + _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew", "// new\n") + assert uu.detect_release_type(str(tmp_path), "2.1.119") == "sea" + + +def test_detect_release_type_finds_legacy_cli_js(tmp_path): + rel = tmp_path / "releases" / "v2.1.100" + rel.mkdir(parents=True) + (rel / "cli.js").write_text("// legacy") + assert uu.detect_release_type(str(tmp_path), "2.1.100") == "cli_js" + + +def test_detect_release_type_returns_none_when_missing(tmp_path): + assert uu.detect_release_type(str(tmp_path), "9.9.9") is None + + +# ---------- install_sea_release ---------- + +def test_install_sea_creates_files_in_install_root(tmp_path): + sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 100, "// wrapper\n") + install_root = tmp_path / "system_install" + _make_existing_install(install_root, version="2.1.100") + + ok = uu.install_sea_release( + repo_root=str(tmp_path), + version="2.1.119", + install_root=str(install_root), + bin_symlink_target=None, + ) + assert ok is True + pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" + assert (pkg / "cli-wrapper.cjs").read_text() == "// wrapper\n" + assert (pkg / "bin" / "claude.exe").exists() + # sha256 must match manifest + manifest = json.loads((sea / "manifest.json").read_text()) + assert _sha256(pkg / "bin" / "claude.exe") == manifest["binary_sha256"] + + +def test_install_sea_returns_false_when_release_missing(tmp_path): + install_root = tmp_path / "system" + install_root.mkdir() + ok = uu.install_sea_release( + repo_root=str(tmp_path), + version="9.9.9", + install_root=str(install_root), + ) + assert ok is False + + +def test_install_sea_creates_backup_of_existing_binary(tmp_path): + _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") + install_root = tmp_path / "system" + _make_existing_install(install_root, version="2.1.100") + + uu.install_sea_release( + repo_root=str(tmp_path), + version="2.1.119", + install_root=str(install_root), + ) + pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" + backups = list((pkg / "bin").glob("claude.exe.bak.*")) + assert len(backups) >= 1, f"no backup created in {pkg / 'bin'}" + + +def test_install_sea_rolls_back_on_sha256_mismatch(tmp_path): + """If the installed binary sha doesn't match the manifest after copy + (e.g. fs corruption), rollback from backup must restore old binary.""" + sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") + install_root = tmp_path / "system" + _make_existing_install(install_root, version="2.1.100") + pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" + binary_dst = pkg / "bin" / "claude.exe" + old_bytes = binary_dst.read_bytes() + + # Tamper manifest to force sha mismatch (simulates corrupted install) + manifest_path = sea / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["binary_sha256"] = "0" * 64 # wrong hash + manifest_path.write_text(json.dumps(manifest, indent=2)) + + ok = uu.install_sea_release( + repo_root=str(tmp_path), + version="2.1.119", + install_root=str(install_root), + ) + assert ok is False + # Old binary must be restored + assert binary_dst.read_bytes() == old_bytes + + +def test_install_sea_uses_atomic_copy(tmp_path): + """Atomic copy must use .new staging so partial copy never leaves + binary_dst in inconsistent state.""" + sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") + install_root = tmp_path / "system" + _make_existing_install(install_root, version="2.1.100") + pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" + + uu.install_sea_release( + repo_root=str(tmp_path), + version="2.1.119", + install_root=str(install_root), + ) + # No leftover .new files (would indicate failed atomic rename) + leftover = list(pkg.rglob("*.new")) + assert leftover == [], f"leftover .new files: {leftover}" + + +def test_atomic_copy_cleans_up_new_on_exception(tmp_path): + """If copy or fsync raises mid-flight, the .new staging file must be + cleaned up (no orphan files left in install_root).""" + src = tmp_path / "src.bin" + src.write_bytes(b"\x7fELFcontent") + dst = tmp_path / "dst.bin" + # Force chmod failure: pass an invalid mode value to trigger TypeError + # AFTER the .new file was created + with pytest.raises(TypeError): + uu._atomic_copy_with_fsync(str(src), str(dst), mode="not-an-int") + # No leftover .new + assert not (tmp_path / "dst.bin.new").exists(), "orphan .new file left behind" + # Original dst never created + assert not dst.exists() + + +def test_install_sea_acquires_lock(tmp_path): + """Verify install creates and releases the .uclaude-update.lock file.""" + _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") + install_root = tmp_path / "system" + _make_existing_install(install_root, version="2.1.100") + + uu.install_sea_release( + repo_root=str(tmp_path), + version="2.1.119", + install_root=str(install_root), + ) + lock_file = install_root / ".uclaude-update.lock" + assert lock_file.exists(), "lock file should be created" + + +def test_install_sea_updates_symlink(tmp_path): + _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") + install_root = tmp_path / "system" + _make_existing_install(install_root, version="2.1.100") + + symlink = tmp_path / "bin" / "claude" + symlink.parent.mkdir(parents=True) + # Old symlink → cli.js (legacy) + old_target = install_root / "node_modules" / "@anthropic-ai" / "claude-code" / "cli.js" + old_target.write_text("// old cli.js stub\n") + os.symlink(str(old_target), str(symlink)) + + uu.install_sea_release( + repo_root=str(tmp_path), + version="2.1.119", + install_root=str(install_root), + bin_symlink_target=str(symlink), + ) + new_target = install_root / "node_modules" / "@anthropic-ai" / "claude-code" / "bin" / "claude.exe" + assert os.readlink(str(symlink)) == str(new_target) diff --git a/claude/uclaude_updater.py b/claude/uclaude_updater.py index b96f1c2..695f221 100755 --- a/claude/uclaude_updater.py +++ b/claude/uclaude_updater.py @@ -893,6 +893,198 @@ def _set_user_env_windows(config): pass +# ============================================================ +# SEA install support (Claude Code 2.1.114+) +# ============================================================ + +import hashlib as _hashlib # local alias to avoid shadowing + + +def _file_sha256(path): + h = _hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def detect_release_type(repo_root, version): + """Determine layout of releases/v/. + + Returns: + "sea" — releases/v/sea/{claude, cli-wrapper.cjs} present + "cli_js" — releases/v/cli.js present (legacy) + None — neither found + """ + rel = os.path.join(repo_root, "releases", f"v{version}") + if os.path.isfile(os.path.join(rel, "sea", "claude")) and \ + os.path.isfile(os.path.join(rel, "sea", "cli-wrapper.cjs")): + return "sea" + if os.path.isfile(os.path.join(rel, "cli.js")): + return "cli_js" + return None + + +def _atomic_copy_with_fsync(src, dst, mode=None): + """Copy src → dst.new, fsync file + parent dir, then atomic rename to dst. + + Crash-safe: if killed mid-copy, dst still points to old content. + fsync on both file and parent directory ensures bytes AND the rename + survive crash/power-loss (per POSIX: rename only durable after parent + fsync). + """ + tmp = dst + ".new" + try: + with open(src, "rb") as fsrc, open(tmp, "wb") as fdst: + shutil.copyfileobj(fsrc, fdst, length=1 << 20) + fdst.flush() + os.fsync(fdst.fileno()) + if mode is not None: + os.chmod(tmp, mode) + # Preserve mtime/atime from source (mirrors copy2 behavior) + src_stat = os.stat(src) + os.utime(tmp, (src_stat.st_atime, src_stat.st_mtime)) + os.replace(tmp, dst) + # fsync the parent dir so the rename itself is durable + # (per dual-critic FIX round 2 — without this, a power-loss after + # rename can leave dst pointing at the old inode) + parent = os.path.dirname(dst) or "." + try: + dir_fd = os.open(parent, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except OSError: + pass # best-effort; some filesystems don't support dir fsync + except Exception: + # Cleanup orphan .new on any failure (per dual-critic FIX round 2) + try: + if os.path.exists(tmp): + os.unlink(tmp) + except OSError: + pass + raise + + +def install_sea_release(repo_root, version, install_root, bin_symlink_target=None): + """Install patched SEA artifacts (binary + wrapper) into install_root. + + install_root layout (after install): + /node_modules/@anthropic-ai/claude-code/ + ├─ bin/claude.exe (patched native binary, mode 0755) + ├─ cli-wrapper.cjs (patched wrapper with ENV overrides) + └─ package.json (preserved if already present) + + Production-hardened (per dual-critic FIX): + 1. Verify sea/manifest.json exists; load expected sha256 hashes + 2. **Pre-verify** sha256 of source files BEFORE touching install_root + 3. Acquire fcntl.flock on install_root/.uclaude-update.lock to block + concurrent runs (cron + manual race) + 4. Backup existing → bin/claude.exe.bak. + 5. **Atomic copy** via .new + fsync + os.replace (crash-safe) + 6. Verify installed sha256; **rollback from backup** on mismatch + 7. Update bin_symlink_target (atomic via tmp_link + os.replace) + + Returns True on success, False if release missing or any verify failure. + """ + sea = os.path.join(repo_root, "releases", f"v{version}", "sea") + binary_src = os.path.join(sea, "claude") + wrapper_src = os.path.join(sea, "cli-wrapper.cjs") + manifest_src = os.path.join(sea, "manifest.json") + if not (os.path.isfile(binary_src) and os.path.isfile(wrapper_src)): + eprint(f" {R}SEA release not found at {sea}{D}") + return False + try: + with open(manifest_src) as f: + manifest = json.load(f) + except (OSError, ValueError) as e: + eprint(f" {R}Cannot read manifest.json: {e}{D}") + return False + + # PRE-verify source sha256 — fail fast without touching install_root + expected_bin_sha = manifest.get("binary_sha256") + expected_wrap_sha = manifest.get("wrapper_sha256") + if expected_bin_sha and _file_sha256(binary_src) != expected_bin_sha: + eprint(f" {R}sha256 mismatch on SOURCE binary {binary_src}{D}") + return False + if expected_wrap_sha and _file_sha256(wrapper_src) != expected_wrap_sha: + eprint(f" {R}sha256 mismatch on SOURCE wrapper{D}") + return False + + pkg = os.path.join(install_root, "node_modules", "@anthropic-ai", "claude-code") + bin_dir = os.path.join(pkg, "bin") + binary_dst = os.path.join(bin_dir, "claude.exe") + wrapper_dst = os.path.join(pkg, "cli-wrapper.cjs") + os.makedirs(bin_dir, exist_ok=True) + + # Acquire advisory lock (concurrent run protection) + lock_path = os.path.join(install_root, ".uclaude-update.lock") + lock_fd = None + try: + import fcntl as _fcntl + lock_fd = open(lock_path, "w") + try: + _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB) + except (BlockingIOError, OSError): + print(f" {Y}Another uclaude_updater run holds the lock; waiting...{D}") + _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_EX) + except ImportError: + pass # Non-POSIX (Windows) — skip lock + + timestamp = time.strftime("%Y%m%d%H%M%S") + binary_backup = None + wrapper_backup = None + try: + # Backup existing files (capture paths for potential rollback) + if os.path.isfile(binary_dst): + binary_backup = f"{binary_dst}.bak.{timestamp}" + shutil.copy2(binary_dst, binary_backup) + if os.path.isfile(wrapper_dst): + wrapper_backup = f"{wrapper_dst}.bak.{timestamp}" + shutil.copy2(wrapper_dst, wrapper_backup) + + # Atomic crash-safe copy + _atomic_copy_with_fsync(binary_src, binary_dst, mode=0o755) + _atomic_copy_with_fsync(wrapper_src, wrapper_dst) + + # Post-install verify; rollback on mismatch (defense vs. fs corruption) + if expected_bin_sha and _file_sha256(binary_dst) != expected_bin_sha: + eprint(f" {R}sha256 mismatch on installed binary — rolling back{D}") + if binary_backup and os.path.isfile(binary_backup): + shutil.move(binary_backup, binary_dst) + return False + if expected_wrap_sha and _file_sha256(wrapper_dst) != expected_wrap_sha: + eprint(f" {R}sha256 mismatch on installed wrapper — rolling back{D}") + if wrapper_backup and os.path.isfile(wrapper_backup): + shutil.move(wrapper_backup, wrapper_dst) + return False + + # Update symlink (atomic via tmp + rename) + if bin_symlink_target: + tmp_link = bin_symlink_target + ".new" + try: + if os.path.lexists(tmp_link): + os.unlink(tmp_link) + os.symlink(binary_dst, tmp_link) + os.replace(tmp_link, bin_symlink_target) + except OSError as e: + eprint(f" {Y}Could not update symlink {bin_symlink_target}: {e}{D}") + + return True + finally: + if lock_fd is not None: + try: + import fcntl as _fcntl + _fcntl.flock(lock_fd.fileno(), _fcntl.LOCK_UN) + except (ImportError, OSError): + pass + try: + lock_fd.close() + except OSError: + pass + + # ============================================================ # Main commands # ============================================================ @@ -984,32 +1176,64 @@ def cmd_update(force=False, settings_only=False): patch_all_users(config) return 0 - # Install cli.js + # Install cli.js (legacy ≤2.1.113) OR SEA layout (≥2.1.114) — dispatch if not settings_only: - all_paths = find_all_cli_js() - if not all_paths: - cli_js_single = find_cli_js() - if cli_js_single: - all_paths = [cli_js_single] - if not all_paths: - eprint(f" {R}Claude Code cli.js not found even after install attempt.{D}") - return 1 - if not is_admin(): - eprint(f" {R}Root/admin privileges required to update cli.js.{D}") + eprint(f" {R}Root/admin privileges required to update Claude Code.{D}") eprint(f" Run with: sudo python3 {sys.argv[0]}") return 1 - print(f"\n{W}--- Installing cli.js v{latest} (found {len(all_paths)} location(s)) ---{D}") - any_ok = False - for path in all_paths: - print(f" Patching: {path}") - ok = install_cli_js(latest, path) - if ok: - any_ok = True - else: - eprint(f" {Y}Failed to patch {path}, continuing...{D}") - if not any_ok: + release_type = detect_release_type(SCRIPT_DIR, latest) + if release_type == "sea": + print(f"\n{W}--- Installing SEA release v{latest} ---{D}") + # System install root (npm convention) + install_root_candidates = [ + "/usr/lib/node_modules/@anthropic-ai/claude-code", + "/usr/local/lib/node_modules/@anthropic-ai/claude-code", + ] + install_root = None + for cand in install_root_candidates: + if os.path.isdir(os.path.dirname(os.path.dirname(cand))): + # Detect the parent npm root containing @anthropic-ai + npm_root = os.path.dirname(os.path.dirname(cand)) + install_root = npm_root + break + if not install_root: + eprint(f" {R}Could not locate npm root (tried /usr/lib, /usr/local/lib){D}") + return 1 + ok = install_sea_release( + repo_root=SCRIPT_DIR, + version=latest, + install_root=install_root, + bin_symlink_target="/usr/bin/claude", + ) + if not ok: + return 1 + print(f" {G}SEA install complete: v{latest}{D}") + elif release_type == "cli_js": + all_paths = find_all_cli_js() + if not all_paths: + cli_js_single = find_cli_js() + if cli_js_single: + all_paths = [cli_js_single] + if not all_paths: + eprint(f" {R}Claude Code cli.js not found even after install attempt.{D}") + return 1 + + print(f"\n{W}--- Installing cli.js v{latest} (found {len(all_paths)} location(s)) ---{D}") + any_ok = False + for path in all_paths: + print(f" Patching: {path}") + ok = install_cli_js(latest, path) + if ok: + any_ok = True + else: + eprint(f" {Y}Failed to patch {path}, continuing...{D}") + if not any_ok: + return 1 + else: + eprint(f" {R}No release artifacts found for v{latest} " + f"(neither sea/ nor cli.js). Run git pull?{D}") return 1 # Patch settings