feat(updater): SEA install support for Claude Code 2.1.114+ (TDD + dual-critic APPROVE)

Adds install_sea_release() to uclaude_updater.py — dispatches by release
type from releases/v<VER>/, 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 <install_root>/.uclaude-update.lock (concurrent run safe)
- Backup existing → .bak.<TIMESTAMP> 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 <noreply@anthropic.com>
This commit is contained in:
delta-cloud-208e
2026-04-24 11:54:17 +00:00
parent 01c7af848f
commit 3d4b371e17
3 changed files with 489 additions and 22 deletions

View File

@@ -0,0 +1,236 @@
"""TDD — uclaude_updater.py SEA install support.
Verifies that install_sea_release():
- Locates releases/v<VERSION>/sea/{claude, cli-wrapper.cjs, manifest.json}
- Installs via npm: ensures @anthropic-ai/claude-code@<VERSION> + 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<VERSION>/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)