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

@@ -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<VERSION>/.
Returns:
"sea" — releases/v<VERSION>/sea/{claude, cli-wrapper.cjs} present
"cli_js" — releases/v<VERSION>/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):
<install_root>/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.<TIMESTAMP>
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