diff --git a/.github/pr91079-actions-kick b/.github/pr91079-actions-kick new file mode 100644 index 000000000000..6985f1dd4cb6 --- /dev/null +++ b/.github/pr91079-actions-kick @@ -0,0 +1 @@ +retrigger fork final-tree publisher from ae143040a06d04210b6449624a6ebd13b12ccc73 diff --git a/.github/pr91079-actions-kick-2 b/.github/pr91079-actions-kick-2 new file mode 100644 index 000000000000..4aca7248d006 --- /dev/null +++ b/.github/pr91079-actions-kick-2 @@ -0,0 +1 @@ +retrigger exact-current-main materializer diff --git a/.github/workflows/pr91079-finalize-current-main.yml b/.github/workflows/pr91079-finalize-current-main.yml new file mode 100644 index 000000000000..b483ab913dfa --- /dev/null +++ b/.github/workflows/pr91079-finalize-current-main.yml @@ -0,0 +1,406 @@ +name: PR 91079 current-main finalizer + +on: + push: + branches: + - fix/windows-desktop-pack-transaction + +permissions: + contents: write + +concurrency: + group: pr91079-current-main-finalizer + cancel-in-progress: false + +jobs: + compose: + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + base_sha: ${{ steps.object.outputs.base_sha }} + final_sha: ${{ steps.object.outputs.final_sha }} + steps: + - name: Checkout carrier history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python test runner + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + + - name: Compose exact current-main product object + id: object + shell: bash + run: | + set -Eeuo pipefail + git config user.name 'Axl Ibiza, MBA' + git config user.email 'andrexibiza@gmail.com' + git remote add upstream https://github.com/NousResearch/hermes-agent.git 2>/dev/null || true + git fetch --no-tags upstream refs/heads/main:refs/remotes/upstream/main + git fetch --no-tags origin db32f7bb0864f5944c6ed9f505d0b979bedc8f2f + BASE=$(git rev-parse refs/remotes/upstream/main) + git checkout --detach "$BASE" + git switch -c pr91079-final-candidate + + paths=( + 'apps/desktop/scripts/before-pack-recovery.mjs' + 'apps/desktop/scripts/before-pack.mjs' + 'apps/desktop/scripts/before-pack.test.mjs' + 'apps/desktop/scripts/desktop-builder-runtime.mjs' + 'apps/desktop/scripts/desktop-pack-recovery-composition.test.mjs' + 'apps/desktop/scripts/desktop-pack-transaction.mjs' + 'apps/desktop/scripts/desktop-pack-transaction.test.mjs' + 'apps/desktop/scripts/run-electron-builder.mjs' + 'apps/desktop/scripts/stage-native-deps-recovery.mjs' + 'apps/desktop/scripts/stage-native-deps-recovery.test.mjs' + 'apps/desktop/vitest.config.ts' + 'tests/hermes_cli/test_desktop_pack_transaction_windows.py' + ) + git checkout db32f7bb0864f5944c6ed9f505d0b979bedc8f2f -- "${paths[@]}" + + python - <<'PY' + from pathlib import Path + p = Path('apps/desktop/package.json') + s = p.read_text() + replacements = ( + ('"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs"', + '"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps-recovery.mjs"', 'build'), + ('"check:test:desktop:all": "npm run test:desktop:all"', + '"check:test:desktop:all": "node --test scripts/stage-native-deps-recovery.test.mjs && npm run test:desktop:all"', 'check:test:desktop:all'), + ('"beforePack": "scripts/before-pack.mjs"', + '"beforePack": "scripts/before-pack-recovery.mjs"', 'beforePack'), + ) + for old, new, label in replacements: + count = s.count(old) + if count != 1: + raise SystemExit(f'{label} anchor count {count}, expected 1') + s = s.replace(old, new, 1) + p.write_text(s) + PY + + python - <<'PY' + from pathlib import Path + p = Path('hermes_cli/main.py') + s = p.read_text() + start = 'def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:\n' + end = '\ndef _ensure_desktop_exe_launchable(\n' + if s.count(start) != 1 or s.count(end) != 1: + raise SystemExit(f'rollback anchors start={s.count(start)} end={s.count(end)}') + a = s.index(start) + b = s.index(end, a) + replacement = '''def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]: + """Restore the previous unpacked desktop app from its ``.bak`` tree. + + Returns the restored executable path, or ``None`` when no usable backup + exists or the filesystem transaction cannot complete. The invalid candidate + is kept alongside as ``.corrupt`` after a successful rollback. + + The live path is never deleted to make room for the backup. A stale + quarantine must be retired before either generation moves; the candidate is + then quarantined by rename, and backup promotion owns the commit point. If + promotion fails, the candidate is moved back to the live path while the + backup remains intact. Best-effort: never raises. + """ + unpacked = packaged_executable.parent + backup_dir = _desktop_backup_unpacked_dir(packaged_executable) + backup_exe = backup_dir / packaged_executable.name + if not backup_exe.exists(): + return None + if _desktop_exe_integrity_error(backup_exe) is not None: + return None + + corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt") + marker_path = backup_dir.with_name(backup_dir.name + ".session") + + if corrupt_dir.exists(): + try: + shutil.rmtree(corrupt_dir) + except FileNotFoundError: + pass + except OSError: + return None + + try: + unpacked.rename(corrupt_dir) + except OSError: + return None + + try: + backup_dir.rename(unpacked) + except OSError: + try: + corrupt_dir.rename(unpacked) + except OSError: + # Both generations remain preserved at explicit paths for manual or + # later recovery; never delete either one here. + pass + return None + + try: + marker_path.unlink() + except OSError: + pass + + restored = unpacked / packaged_executable.name + return restored if restored.exists() else None + ''' + # The triple-quoted workflow literal is indented by YAML/Python source. + import textwrap + replacement = textwrap.dedent(replacement) + p.write_text(s[:a] + replacement + s[b:]) + PY + + python - <<'PY' + from pathlib import Path + p = Path('tests/hermes_cli/test_desktop_pack_transaction_windows.py') + s = p.read_text() + marker = 'def test_real_packaged_windows_recovery_transaction(' + if marker not in s: + s += r''' + + +def test_real_packaged_windows_recovery_transaction(tmp_path): + """Recover and launch the exact electron-builder Windows package witness.""" + import hashlib + import os + import shutil + import subprocess + import sys + from pathlib import Path + + import pytest + import hermes_cli.main as main + + if sys.platform != "win32": + pytest.skip("requires Windows PE launch") + source_value = os.environ.get("HERMES_REAL_PACKAGED_WINDOWS_EXE") + if not source_value: + pytest.skip("HERMES_REAL_PACKAGED_WINDOWS_EXE is not set") + + source_exe = Path(source_value).resolve() + assert source_exe.is_file(), source_exe + assert main._desktop_exe_integrity_error(source_exe) is None + + candidate_dir = tmp_path / source_exe.parent.name + shutil.copytree(source_exe.parent, candidate_dir) + candidate_exe = candidate_dir / source_exe.name + backup_dir = main._desktop_backup_unpacked_dir(candidate_exe) + shutil.copytree(candidate_dir, backup_dir) + backup_exe = backup_dir / source_exe.name + backup_digest = hashlib.sha256(backup_exe.read_bytes()).hexdigest() + + candidate_exe.write_bytes(b"MZ" + (b"\\0" * 62)) + assert main._desktop_exe_integrity_error(candidate_exe) is not None + + restored = main._rollback_desktop_from_backup(candidate_exe) + assert restored == candidate_exe + assert restored.is_file() + assert hashlib.sha256(restored.read_bytes()).hexdigest() == backup_digest + assert main._desktop_exe_integrity_error(restored) is None + + quarantined_exe = candidate_dir.with_name(candidate_dir.name + ".corrupt") / source_exe.name + assert quarantined_exe.is_file() + assert main._desktop_exe_integrity_error(quarantined_exe) is not None + + completed = subprocess.run( + [str(restored), "--version"], + cwd=restored.parent, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert completed.returncode == 0, (completed.stdout, completed.stderr) +''' + p.write_text(s) + PY + + cat > /tmp/expected-pr91079-paths <<'EOF' + apps/desktop/package.json + apps/desktop/scripts/before-pack-recovery.mjs + apps/desktop/scripts/before-pack.mjs + apps/desktop/scripts/before-pack.test.mjs + apps/desktop/scripts/desktop-builder-runtime.mjs + apps/desktop/scripts/desktop-pack-recovery-composition.test.mjs + apps/desktop/scripts/desktop-pack-transaction.mjs + apps/desktop/scripts/desktop-pack-transaction.test.mjs + apps/desktop/scripts/run-electron-builder.mjs + apps/desktop/scripts/stage-native-deps-recovery.mjs + apps/desktop/scripts/stage-native-deps-recovery.test.mjs + apps/desktop/vitest.config.ts + hermes_cli/main.py + tests/hermes_cli/test_desktop_pack_transaction_windows.py + EOF + git diff --name-only "$BASE" -- | LC_ALL=C sort > /tmp/actual-pr91079-paths + diff -u /tmp/expected-pr91079-paths /tmp/actual-pr91079-paths + test "$(wc -l < /tmp/actual-pr91079-paths)" -eq 14 + git diff --check "$BASE" -- + python -m py_compile hermes_cli/main.py tests/hermes_cli/test_desktop_pack_transaction_windows.py + node --check apps/desktop/scripts/before-pack-recovery.mjs + node --check apps/desktop/scripts/before-pack.mjs + node --check apps/desktop/scripts/desktop-builder-runtime.mjs + node --check apps/desktop/scripts/desktop-pack-transaction.mjs + node --check apps/desktop/scripts/run-electron-builder.mjs + node --check apps/desktop/scripts/stage-native-deps-recovery.mjs + node --test \ + apps/desktop/scripts/before-pack.test.mjs \ + apps/desktop/scripts/desktop-pack-transaction.test.mjs \ + apps/desktop/scripts/stage-native-deps-recovery.test.mjs \ + apps/desktop/scripts/desktop-pack-recovery-composition.test.mjs + python -m pytest -q tests/hermes_cli/test_desktop_pack_transaction_windows.py + + git add -- $(cat /tmp/actual-pr91079-paths) + git commit -m 'fix(desktop): make Windows packaging recovery transactional' + FINAL=$(git rev-parse HEAD) + test "$(git rev-parse HEAD^)" = "$BASE" + test "$(git rev-list --count "$BASE..$FINAL")" -eq 1 + test "$(git show -s --format='%an <%ae>' "$FINAL")" = 'Axl Ibiza, MBA ' + test "$(git show -s --format='%cn <%ce>' "$FINAL")" = 'Axl Ibiza, MBA ' + git push --force origin "$FINAL:refs/heads/pr91079-final-candidate" + echo "base_sha=$BASE" >> "$GITHUB_OUTPUT" + echo "final_sha=$FINAL" >> "$GITHUB_OUTPUT" + printf '{"base":"%s","final":"%s","paths":14}\n' "$BASE" "$FINAL" | tee /tmp/pr91079-candidate.json + + - name: Upload candidate receipt + uses: actions/upload-artifact@v4 + with: + name: pr91079-current-main-candidate + path: /tmp/pr91079-candidate.json + if-no-files-found: error + + packaged-windows-witness: + needs: compose + runs-on: windows-latest + timeout-minutes: 60 + env: + TARGET_SHA: ${{ needs.compose.outputs.final_sha }} + steps: + - name: Checkout exact candidate object + uses: actions/checkout@v4 + with: + repository: andrexibiza/hermes-agent + ref: ${{ env.TARGET_SHA }} + fetch-depth: 1 + + - name: Prove exact checkout + shell: pwsh + run: | + $actual = git rev-parse HEAD + if ($actual -ne $env:TARGET_SHA) { throw "checkout $actual != target $env:TARGET_SHA" } + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + shell: pwsh + run: | + npm ci + python -m pip install --upgrade pip + python -m pip install -e . pytest + + - name: Build real unpacked Windows package through transaction wrapper + shell: pwsh + working-directory: apps/desktop + run: | + npm run build + node scripts/run-electron-builder.mjs --win --x64 --dir + + - name: Locate packaged executable + id: package + shell: pwsh + run: | + $exe = Get-ChildItem -Path apps/desktop -Recurse -File -Filter '*.exe' | + Where-Object { $_.FullName -match 'win[^\\]*unpacked' -and $_.Name -notmatch 'uninstall' } | + Select-Object -First 1 + if (-not $exe) { throw 'No unpacked Windows executable found' } + "exe=$($exe.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "Packaged executable: $($exe.FullName)" + + - name: Execute real packaged recovery and loader gate + shell: pwsh + env: + HERMES_REAL_PACKAGED_WINDOWS_EXE: ${{ steps.package.outputs.exe }} + run: | + python -m pytest -q tests/hermes_cli/test_desktop_pack_transaction_windows.py -k real_packaged_windows_recovery_transaction + + - name: Emit independent witness receipt + shell: pwsh + env: + PACKAGED_EXE: ${{ steps.package.outputs.exe }} + run: | + New-Item -ItemType Directory -Force -Path witness | Out-Null + $actual = git rev-parse HEAD + $digest = (Get-FileHash -Algorithm SHA256 -Path $env:PACKAGED_EXE).Hash.ToLowerInvariant() + $version = & $env:PACKAGED_EXE --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "packaged executable --version failed: $version" } + [ordered]@{ + target_sha = $env:TARGET_SHA + checked_out_sha = $actual + packaged_executable = $env:PACKAGED_EXE + packaged_executable_sha256 = $digest + loader_exit_code = $LASTEXITCODE + loader_output = $version.Trim() + recovery_test = 'passed' + } | ConvertTo-Json | Set-Content witness/receipt.json + Get-Content witness/receipt.json + + - name: Upload witness receipt + uses: actions/upload-artifact@v4 + with: + name: pr91079-packaged-windows-recovery + path: witness/receipt.json + if-no-files-found: error + + publish: + needs: [compose, packaged-windows-witness] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout carrier ref + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Publish exact verified object and remove candidate ref + shell: bash + env: + BASE_SHA: ${{ needs.compose.outputs.base_sha }} + FINAL_SHA: ${{ needs.compose.outputs.final_sha }} + CARRIER_SHA: ${{ github.sha }} + run: | + set -Eeuo pipefail + git config user.name 'Axl Ibiza, MBA' + git config user.email 'andrexibiza@gmail.com' + git remote add upstream https://github.com/NousResearch/hermes-agent.git 2>/dev/null || true + git fetch --no-tags upstream refs/heads/main:refs/remotes/upstream/main + test "$(git rev-parse refs/remotes/upstream/main)" = "$BASE_SHA" + git fetch --no-tags origin refs/heads/pr91079-final-candidate:refs/remotes/origin/pr91079-final-candidate + test "$(git rev-parse refs/remotes/origin/pr91079-final-candidate)" = "$FINAL_SHA" + test "$(git rev-parse "$FINAL_SHA^")" = "$BASE_SHA" + test "$(git rev-list --count "$BASE_SHA..$FINAL_SHA")" -eq 1 + git push --force-with-lease=refs/heads/fix/windows-desktop-pack-transaction:$CARRIER_SHA origin "$FINAL_SHA:refs/heads/fix/windows-desktop-pack-transaction" + git push origin :refs/heads/pr91079-final-candidate + printf '{"base":"%s","final":"%s","carrier":"%s","windows_witness":"passed"}\n' "$BASE_SHA" "$FINAL_SHA" "$CARRIER_SHA" | tee /tmp/pr91079-published.json + + - name: Upload publication receipt + uses: actions/upload-artifact@v4 + with: + name: pr91079-published-current-main + path: /tmp/pr91079-published.json + if-no-files-found: error diff --git a/.github/workflows/pr91079-materialize-patch.yml b/.github/workflows/pr91079-materialize-patch.yml new file mode 100644 index 000000000000..2d80efe36d95 --- /dev/null +++ b/.github/workflows/pr91079-materialize-patch.yml @@ -0,0 +1,47 @@ +name: PR 91079 upstream commit-object authority probe + +on: + pull_request: + types: [synchronize, edited] + +permissions: + contents: write + +jobs: + probe: + if: >- + github.event.pull_request.number == 91079 && + github.event.pull_request.head.repo.full_name == 'andrexibiza/hermes-agent' && + github.event.pull_request.head.ref == 'fix/windows-desktop-pack-transaction' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Create one unreferenced exact-author commit object + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + repo="$GITHUB_REPOSITORY" + main_sha="$(gh api "repos/$repo/git/ref/heads/main" --jq '.object.sha')" + main_tree="$(gh api "repos/$repo/git/commits/$main_sha" --jq '.tree.sha')" + authored_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + + jq -n \ + --arg message 'chore(pr91079): verify upstream commit-object authority' \ + --arg tree "$main_tree" \ + --arg parent "$main_sha" \ + --arg name 'Axl Ibiza, MBA' \ + --arg email 'andrexibiza@gmail.com' \ + --arg date "$authored_at" \ + '{message:$message, tree:$tree, parents:[$parent], author:{name:$name,email:$email,date:$date}, committer:{name:$name,email:$email,date:$date}}' \ + | gh api --method POST "repos/$repo/git/commits" --input - > "$RUNNER_TEMP/commit.json" + + object_sha="$(jq -r '.sha' "$RUNNER_TEMP/commit.json")" + test -n "$object_sha" + gh api "repos/$repo/git/commits/$object_sha" > "$RUNNER_TEMP/verified.json" + test "$(jq -r '.author.name' "$RUNNER_TEMP/verified.json")" = 'Axl Ibiza, MBA' + test "$(jq -r '.author.email' "$RUNNER_TEMP/verified.json")" = 'andrexibiza@gmail.com' + test "$(jq -r '.committer.name' "$RUNNER_TEMP/verified.json")" = 'Axl Ibiza, MBA' + test "$(jq -r '.committer.email' "$RUNNER_TEMP/verified.json")" = 'andrexibiza@gmail.com' + echo "created_unreferenced_commit_object=$object_sha" diff --git a/.github/workflows/pr91079-publish-final.yml b/.github/workflows/pr91079-publish-final.yml new file mode 100644 index 000000000000..71ff36041baf --- /dev/null +++ b/.github/workflows/pr91079-publish-final.yml @@ -0,0 +1,23 @@ +name: PR 91079 Actions execution probe + +on: + push: + branches: + - fix/windows-desktop-pack-transaction + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + steps: + - name: Record exact event object + shell: bash + run: | + set -euo pipefail + echo "repository=$GITHUB_REPOSITORY" + echo "ref=$GITHUB_REF" + echo "sha=$GITHUB_SHA" + test "$GITHUB_REPOSITORY" = "andrexibiza/hermes-agent" + test "$GITHUB_REF_NAME" = "fix/windows-desktop-pack-transaction" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ef1a9f6d1697..8f99cdbe3341 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,7 +28,7 @@ "profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", "prebuild": "npm run clean", - "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", + "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps-recovery.mjs", "postbuild": "node scripts/assert-dist-built.mjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", @@ -71,7 +71,7 @@ "check:test:ui:shard-1of3": "node scripts/run-ui-shard.mjs", "check:test:ui:shard-2of3": "node scripts/run-ui-shard.mjs", "check:test:ui:shard-3of3": "node scripts/run-ui-shard.mjs", - "check:test:desktop:all": "npm run test:desktop:all", + "check:test:desktop:all": "node --test scripts/stage-native-deps-recovery.test.mjs && npm run test:desktop:all", "check:lint": "npm run typecheck && npm run lint", "check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all", "test:e2e": "npm run build && playwright test e2e/", @@ -212,7 +212,7 @@ "package.json" ], "beforeBuild": "scripts/before-build.mjs", - "beforePack": "scripts/before-pack.mjs", + "beforePack": "scripts/before-pack-recovery.mjs", "afterPack": "scripts/after-pack.mjs", "extraResources": [ { diff --git a/apps/desktop/scripts/before-pack-recovery.mjs b/apps/desktop/scripts/before-pack-recovery.mjs new file mode 100644 index 000000000000..b96876cf323c --- /dev/null +++ b/apps/desktop/scripts/before-pack-recovery.mjs @@ -0,0 +1,45 @@ +// Recovery wrapper around the canonical electron-builder beforePack hook. +// The base hook remains the owner of stale-app cleanup, rollback preservation, +// and native staging. This wrapper handles only one residual: a corrupt or +// unresolvable get-windows package root on a supported native build host. + +import { Arch } from 'electron-builder' + +import beforePack from './before-pack.mjs' +import { + isMissingGetWindowsPackageError, + stageGetWindowsWithRecovery +} from './stage-native-deps-recovery.mjs' + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error) +} + +export default async function beforePackWithRecovery(context) { + try { + return await beforePack(context) + } catch (error) { + if (!isMissingGetWindowsPackageError(error)) { + throw error + } + + const platform = context && context.electronPlatformName + const arch = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined + if (!platform || !arch) { + throw error + } + + console.warn( + `[before-pack] canonical native staging found a corrupt get-windows package root; ` + + `retrying ${platform}-${arch} from an isolated dependency realization` + ) + try { + stageGetWindowsWithRecovery({ platform, arch }) + console.log(`[before-pack] recovered and staged get-windows for target ${platform}-${arch}`) + } catch (recoveryError) { + throw new Error( + `[before-pack] isolated get-windows recovery failed for ${platform}-${arch}: ${errorMessage(recoveryError)}` + ) + } + } +} diff --git a/apps/desktop/scripts/before-pack.mjs b/apps/desktop/scripts/before-pack.mjs index 20045cf728ab..65fe999ee37d 100644 --- a/apps/desktop/scripts/before-pack.mjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -29,17 +29,18 @@ * * The packaging step is not idempotent across an interrupted run, so we make * it idempotent ourselves: wipe the target unpacked directory up front so - * electron-builder always stages into a clean tree. This is safe — the - * directory is a pure build artifact that electron-builder fully recreates - * on every pack; nothing else depends on its prior contents. + * electron-builder always stages into a clean tree. This is safe for stale or + * structurally incomplete output. On Windows, however, a valid current app is + * user rollback material: destructive replacement is allowed only after that + * generation has been acquired transactionally. * * Cross-platform: the same partial-state trap exists on macOS * (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we * clean whatever `appOutDir` electron-builder hands us regardless of platform. * - * Best-effort: a cleanup failure must never mask the real build. We log and - * resolve rather than throw — worst case electron-builder hits the original - * ENOENT, which is no worse than not having this hook at all. + * Best-effort cleanup applies to stale/partial trees. Failure to acquire + * rollback authority for a valid Windows app is different: the hook fails + * closed rather than deleting the current working generation. * * 2. Re-stages node-pty's native files for the ACTUAL target platform/arch * of this pack. `npm run build` already staged node-pty once for the @@ -61,6 +62,43 @@ import { existsSync, rmSync, renameSync } from 'node:fs' import path from 'node:path' import { Arch } from 'electron-builder' import { stageNodePty, stageGetWindows } from './stage-native-deps.mjs' +import { + PACK_SESSION_ENV, + clearRollbackSession, + readRollbackSession, + writeRollbackSession +} from './desktop-pack-transaction.mjs' + +export const ROLLBACK_ACQUISITION_STATUS = Object.freeze({ + PRESERVED: 'preserved', + SAFE_TO_CLEAN: 'safe-to-clean', + BLOCKED: 'blocked' +}) + +function rollbackResult(status, reason, error, details = {}) { + return { + status, + reason, + ...(error ? { error } : {}), + ...details + } +} + +function rollbackOperations(overrides) { + const supplied = overrides && typeof overrides === 'object' ? overrides : {} + return { + existsSync: supplied.existsSync ?? existsSync, + rmSync: supplied.rmSync ?? rmSync, + renameSync: supplied.renameSync ?? renameSync, + clearRollbackSession: supplied.clearRollbackSession ?? clearRollbackSession, + readRollbackSession: supplied.readRollbackSession ?? readRollbackSession, + writeRollbackSession: supplied.writeRollbackSession ?? writeRollbackSession + } +} + +function removeTree(rm, target) { + rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +} export function cleanStaleAppOutDir(appOutDir) { if (!appOutDir || typeof appOutDir !== 'string') { @@ -72,7 +110,7 @@ export function cleanStaleAppOutDir(appOutDir) { // Recursive + force so a half-written tree (read-only bits, partial files) // can't block the wipe. retry/maxRetries rides out transient EBUSY on // Windows where an AV/indexer may briefly hold a handle. - rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + removeTree(rmSync, appOutDir) return true } @@ -82,52 +120,210 @@ export function cleanStaleAppOutDir(appOutDir) { * exe (i.e. it is a previously-working build, not the corrupted partial state * cleanStaleAppOutDir exists to remove). If the fresh pack then produces a * Hermes.exe that Windows can't load (truncated PE from a corrupt cached - * Electron zip, wrong arch), the updater's integrity gate in - * `hermes desktop --build-only` (hermes_cli/main.py - * `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the - * user with "This app can't run on your computer". + * Electron zip, wrong arch), the builder transaction restores this .bak + * instead of leaving the user with "This app can't run on your computer". * - * Returns true when the tree was preserved (appOutDir no longer exists), false - * when there was nothing worth preserving (caller falls through to the wipe). - * A rename failure (AV holding a handle) also returns false — the wipe is the - * safe fallback and matches pre-#69179 behavior exactly. + * One electron-builder invocation may run beforePack more than once (multiple + * Windows targets/architectures). The wrapper supplies one pack-session ID. + * A matching `.bak.session` proves the backup already belongs to + * this invocation, so later targets clean their intermediate output without + * overwriting the original rollback generation. + * + * The result is deliberately multi-state: + * + * - `preserved`: rollback authority exists for this generation; + * - `safe-to-clean`: the current tree is absent/partial and may be wiped; + * - `blocked`: a valid current or backup generation exists, but rollback + * authority could not be acquired. The caller must fail closed. */ -export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') { - if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) { - return false +export function preserveRollbackBackup( + appOutDir, + productExeName = 'Hermes.exe', + sessionId = process.env[PACK_SESSION_ENV], + operationOverrides +) { + const operations = rollbackOperations(operationOverrides) + if (!appOutDir || typeof appOutDir !== 'string') { + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.SAFE_TO_CLEAN, + 'invalid-or-missing-app-output' + ) } - if (!existsSync(path.join(appOutDir, productExeName))) { + + const backupDir = `${appOutDir}.bak` + const currentExe = path.join(appOutDir, productExeName) + const backupExe = path.join(backupDir, productExeName) + + if (!operations.existsSync(currentExe)) { // Partial/corrupt tree (interrupted prior pack) — not rollback material. - return false + // A valid backup from an interrupted older invocation remains useful, but + // it must be adopted into this generation before packaging may proceed. + if (sessionId && operations.existsSync(backupExe)) { + try { + operations.writeRollbackSession(backupDir, sessionId) + } catch (error) { + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.BLOCKED, + 'existing-backup-adoption-failed', + error, + { backupDir } + ) + } + } + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.SAFE_TO_CLEAN, + 'current-package-is-partial-or-absent', + undefined, + { backupDir } + ) } - const backupDir = `${appOutDir}.bak` + + const sameSessionBackup = + Boolean(sessionId) && + operations.existsSync(backupExe) && + operations.readRollbackSession(backupDir) === sessionId + + if (sameSessionBackup) { + // Multi-target pack: keep the first (pre-build) generation as authority. + // The current tree is output from an earlier target in this same builder + // process and must not replace the rollback generation. + try { + removeTree(operations.rmSync, appOutDir) + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.PRESERVED, + 'same-session-backup-retained', + undefined, + { backupDir } + ) + } catch (error) { + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.BLOCKED, + 'same-session-output-cleanup-failed', + error, + { backupDir } + ) + } + } + try { - rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) - renameSync(appOutDir, backupDir) - return true - } catch { - return false + // Do not touch the valid current app unless the prior rollback slot and + // marker can first be retired. A locked marker therefore blocks packaging. + operations.clearRollbackSession(backupDir) + removeTree(operations.rmSync, backupDir) + } catch (error) { + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.BLOCKED, + 'rollback-slot-retirement-failed', + error, + { backupDir } + ) + } + + // Stage the generation identity before moving the current package. If marker + // creation fails, the live app has not been touched. If the subsequent + // directory rename fails, marker cleanup is best-effort but the live app + // still remains at appOutDir. + if (sessionId) { + try { + operations.writeRollbackSession(backupDir, sessionId) + } catch (error) { + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.BLOCKED, + 'rollback-session-write-failed', + error, + { backupDir, currentPackageUntouched: true } + ) + } + } + + try { + operations.renameSync(appOutDir, backupDir) + } catch (error) { + let markerCleanupError + if (sessionId) { + try { + operations.clearRollbackSession(backupDir) + } catch (cleanupError) { + markerCleanupError = cleanupError + } + } + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.BLOCKED, + 'current-package-preservation-failed', + error, + { + backupDir, + currentPackageUntouched: true, + ...(markerCleanupError ? { markerCleanupError } : {}) + } + ) } + + return rollbackResult( + ROLLBACK_ACQUISITION_STATUS.PRESERVED, + 'current-package-preserved', + undefined, + { backupDir } + ) +} + +function rollbackBlockMessage(appOutDir, acquisition) { + const primary = + acquisition.error instanceof Error ? acquisition.error.message : String(acquisition.error || '') + const markerCleanup = + acquisition.markerCleanupError instanceof Error + ? `; cleaning the staged rollback marker also failed: ${acquisition.markerCleanupError.message}` + : '' + const detail = primary ? `: ${primary}` : '' + return ( + `[before-pack] refusing destructive Windows package replacement for ${appOutDir}: ` + + `rollback acquisition blocked (${acquisition.reason})${detail}${markerCleanup}` + ) } -export default async function beforePack(context) { +export default async function beforePack( + context, + { + rollbackOperations: operationOverrides, + rollbackSessionId = process.env[PACK_SESSION_ENV] + } = {} +) { const appOutDir = context && context.appOutDir const platformName = context && context.electronPlatformName - try { - // Windows: keep the previous working build as rollback material for the - // post-build integrity gate (#69179) instead of destroying it. Falls - // through to the plain wipe when the old tree is partial/corrupt or the - // rename fails. - const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe` - if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) { + const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe` + + if (platformName === 'win32') { + const acquisition = preserveRollbackBackup( + appOutDir, + productExe, + rollbackSessionId, + operationOverrides + ) + if (acquisition.status === ROLLBACK_ACQUISITION_STATUS.BLOCKED) { + throw new Error(rollbackBlockMessage(appOutDir, acquisition)) + } + if (acquisition.status === ROLLBACK_ACQUISITION_STATUS.PRESERVED) { console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`) - } else if (cleanStaleAppOutDir(appOutDir)) { - console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`) + } else { + try { + if (cleanStaleAppOutDir(appOutDir)) { + console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`) + } + } catch (err) { + // A stale/partial tree is not rollback authority. Keep cleanup + // best-effort so electron-builder can surface its canonical failure. + console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) + } + } + } else { + try { + if (cleanStaleAppOutDir(appOutDir)) { + console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`) + } + } catch (err) { + // Non-Windows cleanup remains best-effort. + console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) } - } catch (err) { - // Never fail the build over cleanup; surface why so a genuinely stuck - // directory (permissions, mount) is still diagnosable. - console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) } try { @@ -155,4 +351,4 @@ export default async function beforePack(context) { // than a build that fails loudly here. throw new Error(`[before-pack] failed to stage native deps for this target: ${err.message}`) } -} \ No newline at end of file +} diff --git a/apps/desktop/scripts/before-pack.test.mjs b/apps/desktop/scripts/before-pack.test.mjs index d082ec4d2ad9..f77c685b6f8e 100644 --- a/apps/desktop/scripts/before-pack.test.mjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -4,7 +4,13 @@ import os from 'node:os' import path from 'node:path' import { test } from 'vitest' -import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs' +import beforePack, { + ROLLBACK_ACQUISITION_STATUS, + cleanStaleAppOutDir, + preserveRollbackBackup +} from '../scripts/before-pack.mjs' + +const { BLOCKED, PRESERVED, SAFE_TO_CLEAN } = ROLLBACK_ACQUISITION_STATUS test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -44,10 +50,7 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => { assert.equal(cleanStaleAppOutDir(42), false) }) -test('beforePack default export resolves even when cleanup throws', async () => { - // A directory path that rmSync can't remove is simulated by passing a - // context whose appOutDir is a file the hook will try (and be allowed) to - // remove; the contract under test is that the hook never rejects. +test('beforePack default export resolves for an empty best-effort cleanup target', async () => { await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' })) }) @@ -61,12 +64,10 @@ test('preserveRollbackBackup moves a working build to .bak', () => { fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8') fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8') - const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe') + const acquisition = preserveRollbackBackup(appOutDir, 'Hermes.exe') - assert.equal(preserved, true) - // Original slot vacated so electron-builder stages into a clean tree... + assert.equal(acquisition.status, PRESERVED) assert.equal(fs.existsSync(appOutDir), false) - // ...and the previous working build is intact under .bak for rollback. assert.equal( fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'MZ-old-build' @@ -85,24 +86,25 @@ test('preserveRollbackBackup replaces a stale .bak from an older update', () => fs.mkdirSync(`${appOutDir}.bak`, { recursive: true }) fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8') - assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true) + const acquisition = preserveRollbackBackup(appOutDir, 'Hermes.exe') + + assert.equal(acquisition.status, PRESERVED) assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current') } finally { fs.rmSync(tempRoot, { recursive: true, force: true }) } }) -test('preserveRollbackBackup refuses a partial tree missing the product exe', () => { - // The corrupted partial state (interrupted prior pack) must NOT become - // rollback material — it is exactly what cleanStaleAppOutDir exists to wipe. +test('preserveRollbackBackup marks a partial tree safe to clean', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) try { const appOutDir = path.join(tempRoot, 'win-unpacked') fs.mkdirSync(appOutDir, { recursive: true }) fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8') - assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false) - // Tree untouched; the caller's wipe path handles it. + const acquisition = preserveRollbackBackup(appOutDir, 'Hermes.exe') + + assert.equal(acquisition.status, SAFE_TO_CLEAN) assert.equal(fs.existsSync(appOutDir), true) assert.equal(fs.existsSync(`${appOutDir}.bak`), false) } finally { @@ -110,11 +112,14 @@ test('preserveRollbackBackup refuses a partial tree missing the product exe', () } }) -test('preserveRollbackBackup ignores missing or invalid input', () => { - assert.equal(preserveRollbackBackup(''), false) - assert.equal(preserveRollbackBackup(undefined), false) - assert.equal(preserveRollbackBackup(null), false) - assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false) +test('preserveRollbackBackup marks missing or invalid input safe to clean', () => { + assert.equal(preserveRollbackBackup('').status, SAFE_TO_CLEAN) + assert.equal(preserveRollbackBackup(undefined).status, SAFE_TO_CLEAN) + assert.equal(preserveRollbackBackup(null).status, SAFE_TO_CLEAN) + assert.equal( + preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')).status, + SAFE_TO_CLEAN + ) }) test('beforePack on win32 preserves the previous build instead of wiping it', async () => { @@ -124,8 +129,6 @@ test('beforePack on win32 preserves the previous build instead of wiping it', as fs.mkdirSync(appOutDir, { recursive: true }) fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8') - // No packager info in the context → default 'Hermes.exe' product name. - // node-pty staging is skipped because arch is not a number here. await beforePack({ appOutDir, electronPlatformName: 'win32' }) assert.equal(fs.existsSync(appOutDir), false) @@ -138,6 +141,160 @@ test('beforePack on win32 preserves the previous build instead of wiping it', as } }) +test('beforePack fails closed when a stale rollback marker cannot be retired', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + const markerPath = `${backupDir}.session` + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-current-working', 'utf8') + fs.mkdirSync(backupDir, { recursive: true }) + fs.writeFileSync(path.join(backupDir, 'Hermes.exe'), 'MZ-older-working', 'utf8') + fs.writeFileSync(markerPath, 'older-session\n', 'utf8') + + await assert.rejects( + beforePack( + { appOutDir, electronPlatformName: 'win32' }, + { + rollbackSessionId: 'new-session', + rollbackOperations: { + clearRollbackSession() { + const error = new Error('simulated locked rollback session marker') + error.code = 'EPERM' + throw error + } + } + } + ), + error => { + assert.match(error.message, /refusing destructive Windows package replacement/) + assert.match(error.message, /rollback-slot-retirement-failed/) + assert.match(error.message, /simulated locked rollback session marker/) + return true + } + ) + + assert.equal(fs.readFileSync(path.join(appOutDir, 'Hermes.exe'), 'utf8'), 'MZ-current-working') + assert.equal(fs.readFileSync(path.join(backupDir, 'Hermes.exe'), 'utf8'), 'MZ-older-working') + assert.equal(fs.readFileSync(markerPath, 'utf8'), 'older-session\n') + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('beforePack leaves the current app untouched when marker creation fails', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + let renameCalled = false + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-current-working', 'utf8') + fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'current-resources', 'utf8') + + await assert.rejects( + beforePack( + { appOutDir, electronPlatformName: 'win32' }, + { + rollbackSessionId: 'write-failure-session', + rollbackOperations: { + writeRollbackSession() { + const error = new Error('simulated rollback session write failure') + error.code = 'EACCES' + throw error + }, + renameSync() { + renameCalled = true + throw new Error('rename must not run after marker failure') + } + } + } + ), + error => { + assert.match(error.message, /refusing destructive Windows package replacement/) + assert.match(error.message, /rollback-session-write-failed/) + assert.match(error.message, /simulated rollback session write failure/) + return true + } + ) + + assert.equal(renameCalled, false) + assert.equal(fs.readFileSync(path.join(appOutDir, 'Hermes.exe'), 'utf8'), 'MZ-current-working') + assert.equal( + fs.readFileSync(path.join(appOutDir, 'resources.pak'), 'utf8'), + 'current-resources' + ) + assert.equal(fs.existsSync(backupDir), false) + assert.equal(fs.existsSync(`${backupDir}.session`), false) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('beforePack clears the staged marker and leaves the current app when rename fails', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-current-working', 'utf8') + + await assert.rejects( + beforePack( + { appOutDir, electronPlatformName: 'win32' }, + { + rollbackSessionId: 'rename-failure-session', + rollbackOperations: { + renameSync() { + const error = new Error('simulated package rename failure') + error.code = 'EPERM' + throw error + } + } + } + ), + error => { + assert.match(error.message, /current-package-preservation-failed/) + assert.match(error.message, /simulated package rename failure/) + return true + } + ) + + assert.equal(fs.readFileSync(path.join(appOutDir, 'Hermes.exe'), 'utf8'), 'MZ-current-working') + assert.equal(fs.existsSync(backupDir), false) + assert.equal(fs.existsSync(`${backupDir}.session`), false) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup reports blocked when rollback acquisition fails', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-current-working', 'utf8') + + const acquisition = preserveRollbackBackup( + appOutDir, + 'Hermes.exe', + 'blocked-session', + { + clearRollbackSession() { + throw new Error('cannot retire rollback slot') + } + } + ) + + assert.equal(acquisition.status, BLOCKED) + assert.equal(acquisition.reason, 'rollback-slot-retirement-failed') + assert.equal(fs.readFileSync(path.join(appOutDir, 'Hermes.exe'), 'utf8'), 'MZ-current-working') + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + test('beforePack on linux keeps the plain wipe (no .bak)', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) try { diff --git a/apps/desktop/scripts/desktop-builder-runtime.mjs b/apps/desktop/scripts/desktop-builder-runtime.mjs new file mode 100644 index 000000000000..f1e9a0a3f7f3 --- /dev/null +++ b/apps/desktop/scripts/desktop-builder-runtime.mjs @@ -0,0 +1,80 @@ +import { existsSync, realpathSync } from 'node:fs' +import path from 'node:path' + +export const BUILDER_REEXEC_GUARD_ENV = 'HERMES_ELECTRON_BUILDER_REEXEC' +export const MIN_BUILDER_NODE_VERSION = '22.22.0' + +function normalizeExecutablePath(filePath, platform, realpath) { + if (!filePath || typeof filePath !== 'string') { + return undefined + } + let normalized + try { + normalized = realpath(filePath) + } catch { + normalized = path.resolve(filePath) + } + normalized = normalized.replace(/[\\/]+$/, '') + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +/** + * npm records the exact Node executable that launched it in npm_node_execpath. + * On Windows, package scripts launched through cmd.exe can resolve a different + * bare `node` from PATH (fnm/system Node), even while npm itself is running on + * Hermes-managed Node. Select npm's runtime for one bounded self-reexec so + * install and package generations cannot silently use different interpreters. + */ +export function selectNpmNodeRuntime({ + currentExecPath, + npmNodeExecPath, + guardValue, + platform = process.platform, + exists = existsSync, + realpath = realpathSync.native +}) { + if (platform !== 'win32' || guardValue === '1') { + return undefined + } + if (!npmNodeExecPath || !exists(npmNodeExecPath)) { + return undefined + } + const current = normalizeExecutablePath(currentExecPath, platform, realpath) + const npmSelected = normalizeExecutablePath(npmNodeExecPath, platform, realpath) + if (!current || !npmSelected || current === npmSelected) { + return undefined + } + return npmNodeExecPath +} + +export function nodeVersionAtLeast(version, minimum = MIN_BUILDER_NODE_VERSION) { + const parse = value => String(value || '').split('.').map(part => Number.parseInt(part, 10)) + const actual = parse(version) + const required = parse(minimum) + if (actual.length < 2 || actual.some(part => !Number.isFinite(part))) { + return false + } + for (let index = 0; index < Math.max(actual.length, required.length); index += 1) { + const left = actual[index] || 0 + const right = required[index] || 0 + if (left !== right) { + return left > right + } + } + return true +} + +export function desktopBuilderRuntimeProblem({ + version, + execPath, + requireModuleSupported, + minimum = MIN_BUILDER_NODE_VERSION +}) { + if (!nodeVersionAtLeast(version, minimum)) { + return `Node ${version || 'unknown'} at ${execPath || 'unknown path'} is too old; Hermes Desktop packaging requires Node >=${minimum}` + } + if (requireModuleSupported !== true) { + return `Node ${version} at ${execPath || 'unknown path'} cannot require ESM modules; remove --no-experimental-require-module and retry` + } + return undefined +} diff --git a/apps/desktop/scripts/desktop-pack-recovery-composition.test.mjs b/apps/desktop/scripts/desktop-pack-recovery-composition.test.mjs new file mode 100644 index 000000000000..d9ca9bdbc607 --- /dev/null +++ b/apps/desktop/scripts/desktop-pack-recovery-composition.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { test } from 'vitest' + +function source(relativePath) { + return fs.readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +test('isolated native recovery remains inside the canonical package transaction', () => { + const packageJson = JSON.parse(source('../package.json')) + const recoveryHook = source('./before-pack-recovery.mjs') + const canonicalHook = source('./before-pack.mjs') + const builderWrapper = source('./run-electron-builder.mjs') + + assert.equal(packageJson.build.beforePack, 'scripts/before-pack-recovery.mjs') + assert.match(packageJson.scripts.build, /stage-native-deps-recovery\.mjs/) + + // Recovery must wrap—not replace—the owner that preserves the prior + // packaged generation and writes its rollback-session identity. + assert.match(recoveryHook, /import beforePack from '\.\/before-pack\.mjs'/) + assert.match(recoveryHook, /isMissingGetWindowsPackageError/) + const canonicalCall = recoveryHook.indexOf('return await beforePack(context)') + const recoveryCall = recoveryHook.indexOf( + 'stageGetWindowsWithRecovery({ platform, arch })' + ) + assert.ok(canonicalCall >= 0, 'canonical beforePack invocation must remain wired') + assert.ok(recoveryCall >= 0, 'bounded recovery invocation must remain wired') + assert.ok( + canonicalCall < recoveryCall, + 'canonical staging and rollback acquisition must run before bounded recovery' + ) + + assert.match(canonicalHook, /PACK_SESSION_ENV/) + assert.match(canonicalHook, /preserveRollbackBackup/) + assert.match(canonicalHook, /desktop-pack-transaction\.mjs/) + + // The terminal builder result—not the recovery helper—owns commit/restore. + assert.match(builderWrapper, /settleDesktopPack/) + assert.match(builderWrapper, /PACK_SESSION_ENV/) + const builderVerdict = builderWrapper.indexOf('const builderSucceeded =') + const settlementCall = builderWrapper.indexOf( + 'const settlement = settleDesktopPack(' + ) + assert.ok(builderVerdict >= 0, 'builder verdict must remain explicit') + assert.ok(settlementCall >= 0, 'terminal transaction settlement must remain wired') + assert.ok( + builderVerdict < settlementCall, + 'the actual builder result must be known before transaction settlement' + ) +}) diff --git a/apps/desktop/scripts/desktop-pack-transaction.mjs b/apps/desktop/scripts/desktop-pack-transaction.mjs new file mode 100644 index 000000000000..a7e749b63863 --- /dev/null +++ b/apps/desktop/scripts/desktop-pack-transaction.mjs @@ -0,0 +1,308 @@ +import { + closeSync, + existsSync, + openSync, + readFileSync, + readSync, + readdirSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import path from 'node:path' + +export const PACK_SESSION_ENV = 'HERMES_DESKTOP_PACK_SESSION' + +export function rollbackSessionMarkerPath(backupDir) { + return `${backupDir}.session` +} + +export function readRollbackSession(backupDir) { + try { + return readFileSync(rollbackSessionMarkerPath(backupDir), 'utf8').trim() || undefined + } catch { + return undefined + } +} + +export function writeRollbackSession(backupDir, sessionId) { + if (!sessionId || typeof sessionId !== 'string') { + return false + } + writeFileSync(rollbackSessionMarkerPath(backupDir), `${sessionId}\n`, 'utf8') + return true +} + +export function clearRollbackSession(backupDir) { + try { + unlinkSync(rollbackSessionMarkerPath(backupDir)) + return true + } catch (error) { + if (error && error.code === 'ENOENT') { + return false + } + throw error + } +} + +/** + * Preliminary PE structure check for the builder boundary. + * + * This deliberately does not claim Windows launchability. The canonical + * `_ensure_desktop_exe_launchable` gate in hermes_cli/main.py owns host-machine + * compatibility and final rollback retirement. This check only rejects output + * that is already provably incomplete before control returns to that gate. + */ +export function isWindowsPeExecutable(filePath) { + let fd + try { + if (!filePath || !existsSync(filePath)) { + return false + } + const stat = statSync(filePath) + if (!stat.isFile() || stat.size < 64) { + return false + } + + fd = openSync(filePath, 'r') + const dosHeader = Buffer.alloc(64) + if (readSync(fd, dosHeader, 0, dosHeader.length, 0) !== dosHeader.length) { + return false + } + if (dosHeader[0] !== 0x4d || dosHeader[1] !== 0x5a) { + return false + } + + const peOffset = dosHeader.readUInt32LE(0x3c) + // Keep malformed or absurd offsets from turning a verification read into + // unbounded filesystem work. A complete COFF header must also fit. + if (peOffset < 64 || peOffset > 16 * 1024 * 1024 || peOffset + 24 > stat.size) { + return false + } + + const coff = Buffer.alloc(24) + if (readSync(fd, coff, 0, coff.length, peOffset) !== coff.length) { + return false + } + if (!coff.subarray(0, 4).equals(Buffer.from([0x50, 0x45, 0x00, 0x00]))) { + return false + } + + const sectionCount = coff.readUInt16LE(6) + const optionalHeaderSize = coff.readUInt16LE(20) + if (sectionCount < 1 || sectionCount > 96) { + return false + } + + const sectionTableOffset = peOffset + 24 + optionalHeaderSize + const sectionTableSize = sectionCount * 40 + if (sectionTableOffset + sectionTableSize > stat.size) { + return false + } + + const section = Buffer.alloc(40) + for (let index = 0; index < sectionCount; index += 1) { + const offset = sectionTableOffset + index * section.length + if (readSync(fd, section, 0, section.length, offset) !== section.length) { + return false + } + const rawSize = section.readUInt32LE(16) + const rawOffset = section.readUInt32LE(20) + if (rawSize > 0 && (rawOffset === 0 || rawOffset + rawSize > stat.size)) { + return false + } + } + + return true + } catch { + return false + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch {} + } + } +} + +function rollbackDirectories(releaseDir) { + if (!releaseDir || !existsSync(releaseDir)) { + return [] + } + try { + return readdirSync(releaseDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.bak')) + .map(entry => path.join(releaseDir, entry.name)) + .sort() + } catch { + return [] + } +} + +function removeTree(target) { + rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +} + +function clearRollbackSessionBestEffort(backupDir) { + try { + clearRollbackSession(backupDir) + } catch {} +} + +/** + * Restore the last-good tree without first destroying the failed replacement. + * + * The replacement is moved aside before the backup is promoted. If promoting + * the backup fails, the replacement is moved back to its original path and the + * backup remains intact. This keeps both sides recoverable across transient + * Windows rename failures instead of producing an empty launcher path. + */ +export function restoreBackup( + backupDir, + originalDir, + { + exists = existsSync, + rename = renameSync, + remove = removeTree + } = {} +) { + const failedDir = `${originalDir}.failed` + const hadOriginal = exists(originalDir) + + // A stale quarantine from an older attempt must be retired before any live + // path moves. Failure here leaves originalDir and backupDir untouched. + if (exists(failedDir)) { + remove(failedDir) + } + + if (hadOriginal) { + rename(originalDir, failedDir) + } + + try { + rename(backupDir, originalDir) + } catch (error) { + let replacementRestoreError + if (hadOriginal && exists(failedDir)) { + try { + rename(failedDir, originalDir) + } catch (restoreError) { + replacementRestoreError = restoreError + } + } + + const details = [ + `could not promote rollback ${backupDir} to ${originalDir}: ${error.message}`, + replacementRestoreError + ? `could not restore failed replacement to its live path: ${replacementRestoreError.message}` + : undefined + ] + .filter(Boolean) + .join('; ') + throw new Error(details) + } + + clearRollbackSessionBestEffort(backupDir) + + // The known-good app is live again. Retire the failed output best-effort; + // if an AV/indexer still holds it, leaving the quarantine is non-destructive + // and the next settlement attempt clears it before moving any live path. + if (hadOriginal && exists(failedDir)) { + try { + remove(failedDir) + } catch {} + } +} + +/** + * Close the builder-owned part of the transaction opened by before-pack.mjs. + * + * A failed builder restores the last packaged app. A successful builder may + * reject and roll back output that is already structurally incomplete, but it + * must retain the rollback generation for the canonical Python launchability + * gate. Builder exit zero plus a plausible PE is not authority to delete the + * last known-good app: host architecture and full launchability are decided + * later by `_ensure_desktop_exe_launchable`. + */ +export function settleDesktopPack({ + releaseDir, + builderSucceeded, + productExeName = 'Hermes.exe', + sessionId, + restoreOperations +}) { + const restored = [] + const retained = [] + const discarded = [] + const failures = [] + + for (const backupDir of rollbackDirectories(releaseDir)) { + const originalDir = backupDir.slice(0, -'.bak'.length) + const backupExe = path.join(backupDir, productExeName) + const originalExe = path.join(originalDir, productExeName) + const replacementValid = isWindowsPeExecutable(originalExe) + const rollbackSession = readRollbackSession(backupDir) + + if (sessionId && rollbackSession !== sessionId) { + // Do not mutate another generation's rollback material. A superficially + // successful builder still cannot pass when its replacement is already + // provably incomplete; preserve the foreign backup for the stronger + // Python/manual recovery path and report the ambiguity. + if (builderSucceeded && !replacementValid) { + failures.push({ + backupDir, + reason: + `electron-builder exited successfully but replacement ${originalExe} is missing or structurally incomplete; ` + + `rollback ${backupExe} belongs to generation ${rollbackSession || 'unknown'}, not ${sessionId}` + }) + } + continue + } + + const backupValid = isWindowsPeExecutable(backupExe) + + try { + if (builderSucceeded && replacementValid) { + // Preserve both the rollback tree and its generation marker. The + // Python launchability gate owns wrong-architecture detection, final + // commit, and rollback retirement. + retained.push(backupDir) + continue + } + + if (!backupValid) { + failures.push({ + backupDir, + reason: builderSucceeded + ? `replacement ${originalExe} is invalid and rollback ${backupExe} is not a structurally complete PE` + : `rollback ${backupExe} is not a structurally complete PE` + }) + continue + } + + restoreBackup(backupDir, originalDir, restoreOperations) + restored.push(originalDir) + if (builderSucceeded) { + failures.push({ + backupDir, + reason: `electron-builder exited successfully but replacement ${originalExe} was missing or structurally incomplete; restored previous build` + }) + } + } catch (error) { + failures.push({ + backupDir, + reason: `could not settle rollback transaction: ${error.message}` + }) + } + } + + return { + ok: failures.length === 0, + restored, + retained, + discarded, + failures + } +} diff --git a/apps/desktop/scripts/desktop-pack-transaction.test.mjs b/apps/desktop/scripts/desktop-pack-transaction.test.mjs new file mode 100644 index 000000000000..56a8b83d87e2 --- /dev/null +++ b/apps/desktop/scripts/desktop-pack-transaction.test.mjs @@ -0,0 +1,406 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' + +import { + ROLLBACK_ACQUISITION_STATUS, + preserveRollbackBackup +} from './before-pack.mjs' +import { + BUILDER_REEXEC_GUARD_ENV, + MIN_BUILDER_NODE_VERSION, + desktopBuilderRuntimeProblem, + selectNpmNodeRuntime +} from './desktop-builder-runtime.mjs' +import { + PACK_SESSION_ENV, + isWindowsPeExecutable, + readRollbackSession, + settleDesktopPack +} from './desktop-pack-transaction.mjs' + +const PE_AMD64 = 0x8664 + +function tempRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-pack-transaction-')) +} + +function writePe(filePath, marker = 0x42, { machine = PE_AMD64, truncateTo } = {}) { + const payload = Buffer.alloc(0x400) + payload[0] = 0x4d + payload[1] = 0x5a + payload.writeUInt32LE(0x80, 0x3c) + payload[0x80] = 0x50 + payload[0x81] = 0x45 + payload[0x82] = 0x00 + payload[0x83] = 0x00 + payload.writeUInt16LE(machine, 0x84) + payload.writeUInt16LE(1, 0x86) + payload.writeUInt16LE(0, 0x94) + payload.writeUInt16LE(0x0002, 0x96) + payload.writeUInt32LE(0x200, 0xa8) + payload.writeUInt32LE(0x200, 0xac) + payload.fill(marker, 0x200) + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, truncateTo === undefined ? payload : payload.subarray(0, truncateTo)) +} + +test('PE verification rejects prefix-only and section-truncated executables', () => { + const root = tempRoot() + try { + const valid = path.join(root, 'valid.exe') + const truncated = path.join(root, 'truncated.exe') + const prefixOnly = path.join(root, 'prefix-only.exe') + writePe(valid) + writePe(truncated, 0x42, { truncateTo: 0x300 }) + fs.writeFileSync(prefixOnly, 'MZ-not-a-complete-pe', 'utf8') + + assert.equal(isWindowsPeExecutable(valid), true) + assert.equal(isWindowsPeExecutable(truncated), false) + assert.equal(isWindowsPeExecutable(prefixOnly), false) + assert.equal(isWindowsPeExecutable(path.join(root, 'missing.exe')), false) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('failed builder restores the last valid packaged app over partial output', () => { + const root = tempRoot() + try { + const releaseDir = path.join(root, 'release') + const appOutDir = path.join(releaseDir, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + writePe(path.join(backupDir, 'Hermes.exe'), 0x11) + fs.writeFileSync(`${backupDir}.session`, 'session-a\n', 'utf8') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'partial.txt'), 'partial', 'utf8') + + const result = settleDesktopPack({ + releaseDir, + builderSucceeded: false, + sessionId: 'session-a' + }) + + assert.equal(result.ok, true) + assert.deepEqual(result.restored, [appOutDir]) + assert.equal(fs.existsSync(backupDir), false) + assert.equal(fs.existsSync(`${backupDir}.session`), false) + assert.equal(isWindowsPeExecutable(path.join(appOutDir, 'Hermes.exe')), true) + assert.equal(fs.existsSync(path.join(appOutDir, 'partial.txt')), false) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('successful builder retains rollback for the canonical launchability gate', () => { + const root = tempRoot() + try { + const releaseDir = path.join(root, 'release') + const appOutDir = path.join(releaseDir, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + writePe(path.join(backupDir, 'Hermes.exe'), 0x11) + writePe(path.join(appOutDir, 'Hermes.exe'), 0x22) + fs.writeFileSync(`${backupDir}.session`, 'session-a\n', 'utf8') + + const result = settleDesktopPack({ + releaseDir, + builderSucceeded: true, + sessionId: 'session-a' + }) + + assert.equal(result.ok, true) + assert.deepEqual(result.retained, [backupDir]) + assert.deepEqual(result.discarded, []) + assert.equal(fs.existsSync(backupDir), true) + assert.equal(fs.existsSync(`${backupDir}.session`), true) + assert.equal(isWindowsPeExecutable(path.join(appOutDir, 'Hermes.exe')), true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('false builder success restores previous app and becomes a failure', () => { + const root = tempRoot() + try { + const releaseDir = path.join(root, 'release') + const appOutDir = path.join(releaseDir, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + writePe(path.join(backupDir, 'Hermes.exe'), 0x11) + fs.writeFileSync(`${backupDir}.session`, 'session-a\n', 'utf8') + writePe(path.join(appOutDir, 'Hermes.exe'), 0x22, { truncateTo: 0x300 }) + + const result = settleDesktopPack({ + releaseDir, + builderSucceeded: true, + sessionId: 'session-a' + }) + + assert.equal(result.ok, false) + assert.deepEqual(result.restored, [appOutDir]) + assert.match(result.failures[0].reason, /exited successfully.*structurally incomplete/) + assert.equal(isWindowsPeExecutable(path.join(appOutDir, 'Hermes.exe')), true) + assert.equal(fs.existsSync(backupDir), false) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + + +test('rollback promotion failure restores the failed output path and preserves the backup', () => { + const root = tempRoot() + try { + const releaseDir = path.join(root, 'release') + const appOutDir = path.join(releaseDir, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + const failedDir = `${appOutDir}.failed` + writePe(path.join(backupDir, 'Hermes.exe'), 0x11) + fs.writeFileSync(`${backupDir}.session`, 'session-a\n', 'utf8') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'partial.txt'), 'failed-output', 'utf8') + + const result = settleDesktopPack({ + releaseDir, + builderSucceeded: false, + sessionId: 'session-a', + restoreOperations: { + rename(source, target) { + if (source === backupDir && target === appOutDir) { + const error = new Error('simulated rollback promotion failure') + error.code = 'EPERM' + throw error + } + fs.renameSync(source, target) + } + } + }) + + assert.equal(result.ok, false) + assert.deepEqual(result.restored, []) + assert.match(result.failures[0].reason, /simulated rollback promotion failure/) + assert.equal( + fs.readFileSync(path.join(appOutDir, 'partial.txt'), 'utf8'), + 'failed-output' + ) + assert.equal(isWindowsPeExecutable(path.join(backupDir, 'Hermes.exe')), true) + assert.equal(fs.readFileSync(`${backupDir}.session`, 'utf8'), 'session-a\n') + assert.equal(fs.existsSync(failedDir), false) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('invalid apparent success cannot hide behind another generation marker', () => { + const root = tempRoot() + try { + const releaseDir = path.join(root, 'release') + const appOutDir = path.join(releaseDir, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + writePe(path.join(backupDir, 'Hermes.exe'), 0x11) + fs.writeFileSync(`${backupDir}.session`, 'other-session\n', 'utf8') + writePe(path.join(appOutDir, 'Hermes.exe'), 0x22, { truncateTo: 0x300 }) + + const result = settleDesktopPack({ + releaseDir, + builderSucceeded: true, + sessionId: 'current-session' + }) + + assert.equal(result.ok, false) + assert.deepEqual(result.restored, []) + assert.match(result.failures[0].reason, /belongs to generation other-session/) + assert.equal(fs.existsSync(backupDir), true) + assert.equal(fs.existsSync(appOutDir), true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('same electron-builder session cannot overwrite the original rollback generation', () => { + const root = tempRoot() + try { + const appOutDir = path.join(root, 'release', 'win-unpacked') + const backupDir = `${appOutDir}.bak` + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'original-generation', 'utf8') + + assert.equal( + preserveRollbackBackup(appOutDir, 'Hermes.exe', 'session-a').status, + ROLLBACK_ACQUISITION_STATUS.PRESERVED + ) + assert.equal(readRollbackSession(backupDir), 'session-a') + + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'first-target-output', 'utf8') + assert.equal( + preserveRollbackBackup(appOutDir, 'Hermes.exe', 'session-a').status, + ROLLBACK_ACQUISITION_STATUS.PRESERVED + ) + + assert.equal(fs.existsSync(appOutDir), false) + assert.equal( + fs.readFileSync(path.join(backupDir, 'Hermes.exe'), 'utf8'), + 'original-generation' + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('a new builder session replaces stale rollback material with the current good app', () => { + const root = tempRoot() + try { + const appOutDir = path.join(root, 'release', 'win-unpacked') + const backupDir = `${appOutDir}.bak` + fs.mkdirSync(backupDir, { recursive: true }) + fs.writeFileSync(path.join(backupDir, 'Hermes.exe'), 'older-generation', 'utf8') + fs.writeFileSync(`${backupDir}.session`, 'stale-session\n', 'utf8') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current-generation', 'utf8') + + assert.equal( + preserveRollbackBackup(appOutDir, 'Hermes.exe', 'new-session').status, + ROLLBACK_ACQUISITION_STATUS.PRESERVED + ) + assert.equal(readRollbackSession(backupDir), 'new-session') + assert.equal( + fs.readFileSync(path.join(backupDir, 'Hermes.exe'), 'utf8'), + 'current-generation' + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('pack settlement ignores rollback material owned by another generation', () => { + const root = tempRoot() + try { + const releaseDir = path.join(root, 'release') + const appOutDir = path.join(releaseDir, 'win-unpacked') + const backupDir = `${appOutDir}.bak` + writePe(path.join(backupDir, 'Hermes.exe'), 0x11) + fs.writeFileSync(`${backupDir}.session`, 'other-session\n', 'utf8') + + const result = settleDesktopPack({ + releaseDir, + builderSucceeded: false, + sessionId: 'current-session' + }) + + assert.equal(result.ok, true) + assert.deepEqual(result.restored, []) + assert.equal(fs.existsSync(backupDir), true) + assert.equal(fs.existsSync(appOutDir), false) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('retry adopts a valid interrupted rollback into its current generation', () => { + const root = tempRoot() + try { + const appOutDir = path.join(root, 'release', 'win-unpacked') + const backupDir = `${appOutDir}.bak` + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'partial.txt'), 'partial', 'utf8') + fs.mkdirSync(backupDir, { recursive: true }) + fs.writeFileSync(path.join(backupDir, 'Hermes.exe'), 'last-good', 'utf8') + fs.writeFileSync(`${backupDir}.session`, 'interrupted-session\n', 'utf8') + + assert.equal( + preserveRollbackBackup(appOutDir, 'Hermes.exe', 'retry-session').status, + ROLLBACK_ACQUISITION_STATUS.SAFE_TO_CLEAN + ) + assert.equal(readRollbackSession(backupDir), 'retry-session') + assert.equal(fs.existsSync(appOutDir), true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('Windows package scripts re-exec with the Node runtime that launched npm', () => { + const selected = selectNpmNodeRuntime({ + currentExecPath: 'C:\\fnm\\node.exe', + npmNodeExecPath: 'C:\\Hermes\\node\\node.exe', + guardValue: undefined, + platform: 'win32', + exists: () => true, + realpath: value => value + }) + assert.equal(selected, 'C:\\Hermes\\node\\node.exe') +}) + +test('runtime hand-off is bounded and ignores the same executable identity', () => { + const common = { + currentExecPath: 'C:\\Hermes\\NODE\\node.exe', + npmNodeExecPath: 'c:\\hermes\\node\\node.exe', + platform: 'win32', + exists: () => true, + realpath: value => value + } + assert.equal(selectNpmNodeRuntime({ ...common, guardValue: undefined }), undefined) + assert.equal( + selectNpmNodeRuntime({ + ...common, + npmNodeExecPath: 'C:\\other\\node.exe', + guardValue: '1' + }), + undefined + ) +}) + +test('runtime hand-off never selects missing or non-Windows npm executables', () => { + const input = { + currentExecPath: '/usr/bin/node', + npmNodeExecPath: '/opt/hermes/node', + guardValue: undefined, + exists: () => true, + realpath: value => value + } + assert.equal(selectNpmNodeRuntime({ ...input, platform: 'linux' }), undefined) + assert.equal( + selectNpmNodeRuntime({ ...input, platform: 'win32', exists: () => false }), + undefined + ) +}) + +test('builder runtime gate rejects stale Node and disabled require(esm)', () => { + assert.equal(MIN_BUILDER_NODE_VERSION, '22.22.0') + assert.match( + desktopBuilderRuntimeProblem({ + version: '20.16.0', + execPath: 'C:\\fnm\\node.exe', + requireModuleSupported: false + }), + /too old/ + ) + assert.match( + desktopBuilderRuntimeProblem({ + version: '22.22.0', + execPath: 'C:\\Hermes\\node.exe', + requireModuleSupported: false + }), + /cannot require ESM/ + ) + assert.equal( + desktopBuilderRuntimeProblem({ + version: '22.22.0', + execPath: 'C:\\Hermes\\node.exe', + requireModuleSupported: true + }), + undefined + ) +}) + +test('production wrapper owns one explicit pack generation', () => { + const source = fs.readFileSync(new URL('./run-electron-builder.mjs', import.meta.url), 'utf8') + assert.equal(PACK_SESSION_ENV, 'HERMES_DESKTOP_PACK_SESSION') + assert.equal(BUILDER_REEXEC_GUARD_ENV, 'HERMES_ELECTRON_BUILDER_REEXEC') + assert.match(source, /selectedNpmNode/) + assert.match(source, /npm_node_execpath/) + assert.ok(source.indexOf('selectedNpmNode') < source.indexOf('const dist = electronDistDir()')) + assert.match(source, /PACK_SESSION_ENV/) + assert.match(source, /settleDesktopPack/) + assert.match(source, /env: \{ \.\.\.process\.env/) +}) diff --git a/apps/desktop/scripts/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs index 38e465612f9e..3df2bb4f40c7 100644 --- a/apps/desktop/scripts/run-electron-builder.mjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -6,10 +6,57 @@ import fs from "node:fs" import path from "node:path" +import { randomUUID } from "node:crypto" import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" import { createRequire } from "node:module" +import { + BUILDER_REEXEC_GUARD_ENV, + desktopBuilderRuntimeProblem, + selectNpmNodeRuntime +} from "./desktop-builder-runtime.mjs" +import { PACK_SESSION_ENV, settleDesktopPack } from "./desktop-pack-transaction.mjs" + const require = createRequire(import.meta.url) +const wrapperPath = fileURLToPath(import.meta.url) +const scriptDir = path.dirname(wrapperPath) +const releaseDir = path.join(path.dirname(scriptDir), "release") + +const selectedNpmNode = selectNpmNodeRuntime({ + currentExecPath: process.execPath, + npmNodeExecPath: process.env.npm_node_execpath, + guardValue: process.env[BUILDER_REEXEC_GUARD_ENV], + exists: fs.existsSync, + realpath: fs.realpathSync.native +}) +if (selectedNpmNode) { + console.warn( + `[run-electron-builder] PATH selected ${process.execPath}; re-executing with npm runtime ${selectedNpmNode}` + ) + const reexec = spawnSync(selectedNpmNode, [wrapperPath, ...process.argv.slice(2)], { + env: { ...process.env, [BUILDER_REEXEC_GUARD_ENV]: "1" }, + stdio: "inherit" + }) + if (reexec.error) { + console.error(`[run-electron-builder] Node runtime hand-off failed: ${reexec.error.message}`) + process.exit(1) + } + process.exit(reexec.status == null ? 1 : reexec.status) +} + +const runtimeProblem = desktopBuilderRuntimeProblem({ + version: process.versions.node, + execPath: process.execPath, + requireModuleSupported: process.features?.require_module +}) +if (runtimeProblem) { + console.error(`[run-electron-builder] ${runtimeProblem}`) + console.error( + "[run-electron-builder] Close stale version-manager shells or run the build through Hermes-managed Node." + ) + process.exit(1) +} function electronDistDir() { try { @@ -48,11 +95,34 @@ if (dist && fs.existsSync(distBinary(dist))) { } args.push(...process.argv.slice(2)) +const packSession = process.env[PACK_SESSION_ENV] || randomUUID() const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], { + env: { ...process.env, [PACK_SESSION_ENV]: packSession }, stdio: "inherit", }) +const builderSucceeded = !result.error && result.status === 0 +const settlement = settleDesktopPack({ releaseDir, builderSucceeded, sessionId: packSession }) + +for (const restoredDir of settlement.restored) { + console.warn(`[run-electron-builder] restored previous packaged app: ${restoredDir}`) +} +for (const retainedDir of settlement.retained) { + console.log( + `[run-electron-builder] retained rollback for canonical launchability verification: ${retainedDir}` + ) +} +for (const discardedDir of settlement.discarded) { + console.log(`[run-electron-builder] discarded verified rollback backup: ${discardedDir}`) +} +for (const failure of settlement.failures) { + console.error(`[run-electron-builder] ${failure.reason}`) +} + if (result.error) { console.error(`[run-electron-builder] spawn failed: ${result.error.message}`) process.exit(1) } +if (!settlement.ok) { + process.exit(1) +} process.exit(result.status == null ? 1 : result.status) diff --git a/apps/desktop/scripts/stage-native-deps-recovery.mjs b/apps/desktop/scripts/stage-native-deps-recovery.mjs new file mode 100644 index 000000000000..205fe7e4b00e --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps-recovery.mjs @@ -0,0 +1,488 @@ +#!/usr/bin/env node +// Isolated dependency-realization wrapper for stage-native-deps.mjs. +// +// The normal Desktop build stages get-windows from the repository's installed +// dependency tree. On Windows, an interrupted/in-place npm extraction can leave +// node_modules/get-windows present but unresolvable (for example, package.json +// is missing). Reusing that tree makes every later Desktop rebuild fail at the +// same stage. This wrapper preserves the fail-closed native staging contract, +// but realizes the exact package in a fresh temporary npm prefix and stages +// from that verified root instead of mutating or trusting active node_modules. +// +// Recovery is intentionally two-phase: +// 1. install with lifecycle scripts disabled; +// 2. prove the realized dependency graph is a subset of the committed lock +// closure and carries the repository override policy; +// 3. only then run get-windows' lifecycle script. +// +// No unreviewed registry/transitive graph may execute code during recovery. + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { stageGetWindows, stageNodePty } from './stage-native-deps.mjs' +import { isMain } from './utils.mjs' + +const require = createRequire(import.meta.url) +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const DEFAULT_REPOSITORY_ROOT = resolve(SCRIPT_DIR, '..', '..', '..') + +export const GET_WINDOWS_RECOVERY_VERSION = '9.3.0' +export const GET_WINDOWS_MISSING_ROOT_MARKER = + '[stage-native-deps] get-windows is not installed; cannot stage its ' + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error) +} + +export function isMissingGetWindowsPackageError(error) { + return errorMessage(error).includes(GET_WINDOWS_MISSING_ROOT_MARKER) +} + +export function canRecoverGetWindowsPackage({ + platform, + arch, + hostPlatform = process.platform, + hostArch = process.arch +}) { + if (platform !== hostPlatform) { + return false + } + + if (platform === 'darwin') { + // get-windows' macOS helper is universal. + return true + } + + if (platform === 'win32') { + // The package's node-pre-gyp lifecycle realizes a host-architecture + // binding. Only consume it when the requested package architecture is the + // same one the active Node process can actually verify and execute. + return arch === hostArch && (arch === 'x64' || arch === 'ia32') + } + + return false +} + +function cleanupRecoveryRoot(recoveryRoot) { + try { + rmSync(recoveryRoot, { + force: true, + maxRetries: 5, + recursive: true, + retryDelay: 100 + }) + return true + } catch (error) { + console.warn( + `[stage-native-deps] could not remove isolated get-windows recovery root ${recoveryRoot}: ${errorMessage(error)}` + ) + return false + } +} + +function lockSignature(entry) { + if ( + !entry || + typeof entry.version !== 'string' || + typeof entry.resolved !== 'string' || + typeof entry.integrity !== 'string' + ) { + return undefined + } + return `${entry.version}\u0000${entry.resolved}\u0000${entry.integrity}` +} + +function resolveLockedDependency(packages, fromPath, dependencyName) { + let prefix = fromPath + while (true) { + const candidate = prefix + ? `${prefix}/node_modules/${dependencyName}` + : `node_modules/${dependencyName}` + if (packages[candidate]) { + return candidate + } + if (!prefix) { + return undefined + } + + const nestedMarker = prefix.lastIndexOf('/node_modules/') + if (nestedMarker >= 0) { + prefix = prefix.slice(0, nestedMarker) + } else if (prefix.startsWith('node_modules/')) { + prefix = '' + } else { + return undefined + } + } +} + +export function committedGetWindowsClosure(repositoryLock) { + const packages = repositoryLock && repositoryLock.packages + if (!packages || typeof packages !== 'object') { + throw new Error('[stage-native-deps] repository package-lock.json has no packages graph') + } + + const rootPath = 'node_modules/get-windows' + const rootEntry = packages[rootPath] + if (!rootEntry || rootEntry.version !== GET_WINDOWS_RECOVERY_VERSION) { + throw new Error( + `[stage-native-deps] repository lock does not authorize get-windows@${GET_WINDOWS_RECOVERY_VERSION}` + ) + } + if (!lockSignature(rootEntry)) { + throw new Error( + '[stage-native-deps] repository get-windows lock entry lacks resolved/integrity provenance' + ) + } + + const visited = new Set() + const queue = [rootPath] + while (queue.length > 0) { + const packagePath = queue.shift() + if (!packagePath || visited.has(packagePath)) { + continue + } + const entry = packages[packagePath] + if (!entry) { + throw new Error( + `[stage-native-deps] repository lock closure references missing package ${packagePath}` + ) + } + visited.add(packagePath) + + const dependencySets = [ + entry.dependencies, + entry.optionalDependencies, + entry.peerDependencies + ] + for (const dependencySet of dependencySets) { + if (!dependencySet || typeof dependencySet !== 'object') { + continue + } + for (const dependencyName of Object.keys(dependencySet)) { + const resolvedPath = resolveLockedDependency(packages, packagePath, dependencyName) + if (resolvedPath && !visited.has(resolvedPath)) { + queue.push(resolvedPath) + } + } + } + } + + return visited +} + +function overrideVersion(overrides, packageName) { + const value = overrides && overrides[packageName] + if (typeof value === 'string') { + return value + } + if (value && typeof value === 'object' && typeof value['.'] === 'string') { + return value['.'] + } + return undefined +} + +export function verifyRecoveryGraphAgainstRepository({ + recoveryLock, + repositoryLock, + repositoryOverrides +}) { + const recoveryPackages = recoveryLock && recoveryLock.packages + const repositoryPackages = repositoryLock && repositoryLock.packages + if (!recoveryPackages || typeof recoveryPackages !== 'object') { + throw new Error('[stage-native-deps] isolated recovery did not produce a package-lock graph') + } + if (!repositoryPackages || typeof repositoryPackages !== 'object') { + throw new Error('[stage-native-deps] repository package-lock graph is unavailable') + } + + const closurePaths = committedGetWindowsClosure(repositoryLock) + const allowedSignatures = new Set() + for (const packagePath of closurePaths) { + const signature = lockSignature(repositoryPackages[packagePath]) + if (signature) { + allowedSignatures.add(signature) + } + } + + const recoveryRootEntry = recoveryPackages['node_modules/get-windows'] + const repositoryRootEntry = repositoryPackages['node_modules/get-windows'] + if ( + lockSignature(recoveryRootEntry) !== lockSignature(repositoryRootEntry) || + recoveryRootEntry?.version !== GET_WINDOWS_RECOVERY_VERSION + ) { + throw new Error( + '[stage-native-deps] isolated get-windows artifact does not match committed resolved/integrity provenance' + ) + } + + for (const [packagePath, entry] of Object.entries(recoveryPackages)) { + if (!packagePath) { + continue + } + const signature = lockSignature(entry) + if (!signature || !allowedSignatures.has(signature)) { + throw new Error( + `[stage-native-deps] isolated recovery package ${packagePath} is not present in the committed get-windows dependency closure` + ) + } + } + + const tarOverride = overrideVersion(repositoryOverrides, 'tar') + if (tarOverride) { + for (const [packagePath, entry] of Object.entries(recoveryPackages)) { + if ( + packagePath === 'node_modules/tar' || + packagePath.endsWith('/node_modules/tar') + ) { + if (entry?.version !== tarOverride) { + throw new Error( + `[stage-native-deps] isolated recovery violates repository tar override: ${entry?.version || 'missing'} != ${tarOverride}` + ) + } + } + } + } + + return true +} + +function readRepositoryDependencyAuthority(repositoryRoot) { + const repositoryManifest = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8') + ) + const repositoryLock = JSON.parse( + readFileSync(join(repositoryRoot, 'package-lock.json'), 'utf8') + ) + return { + overrides: + repositoryManifest.overrides && typeof repositoryManifest.overrides === 'object' + ? repositoryManifest.overrides + : {}, + repositoryLock + } +} + +export function recoverGetWindowsPackage({ + platform = process.platform, + arch = process.arch, + npmExecPath = process.env.npm_execpath, + run = spawnSync, + tempParent = tmpdir(), + repositoryRoot = DEFAULT_REPOSITORY_ROOT +} = {}) { + if (!npmExecPath) { + throw new Error( + '[stage-native-deps] cannot recover get-windows: npm_execpath is unavailable; run the Desktop build through npm' + ) + } + + const { overrides, repositoryLock } = readRepositoryDependencyAuthority(repositoryRoot) + // Fail before network/process work if the committed lock itself cannot prove + // the recovery root. + committedGetWindowsClosure(repositoryLock) + + const recoveryRoot = mkdtempSync(join(tempParent, 'hermes-get-windows-')) + let completed = false + + try { + const recoveryManifest = { + name: 'hermes-get-windows-recovery', + private: true, + version: '0.0.0', + dependencies: { + 'get-windows': GET_WINDOWS_RECOVERY_VERSION + }, + overrides, + allowScripts: { + [`get-windows@${GET_WINDOWS_RECOVERY_VERSION}`]: true + } + } + writeFileSync( + join(recoveryRoot, 'package.json'), + `${JSON.stringify(recoveryManifest, null, 2)}\n`, + 'utf8' + ) + + const npmEnv = { + ...process.env, + npm_config_arch: arch, + npm_config_platform: platform, + npm_config_target_arch: arch + } + + // Phase 1: materialize bytes only. No dependency lifecycle script is + // allowed to execute before the realized graph is checked against the + // repository's committed lock and overrides. + const installResult = run( + process.execPath, + [ + npmExecPath, + 'install', + '--workspaces=false', + '--include=optional', + '--ignore-scripts=true', + '--no-audit', + '--no-fund', + '--package-lock=true', + '--prefer-online' + ], + { + cwd: recoveryRoot, + env: npmEnv, + stdio: 'inherit' + } + ) + + if (installResult.error) { + throw new Error( + `[stage-native-deps] isolated get-windows recovery could not start npm: ${installResult.error.message}` + ) + } + if (installResult.status !== 0) { + throw new Error( + `[stage-native-deps] isolated get-windows recovery install exited with ${installResult.status}` + ) + } + + const recoveryLockPath = join(recoveryRoot, 'package-lock.json') + const recoveryLock = JSON.parse(readFileSync(recoveryLockPath, 'utf8')) + verifyRecoveryGraphAgainstRepository({ + recoveryLock, + repositoryLock, + repositoryOverrides: overrides + }) + + // Phase 2: now that every installed registry artifact is authorized by the + // committed get-windows closure, allow only get-windows' lifecycle to run. + const rebuildResult = run( + process.execPath, + [ + npmExecPath, + 'rebuild', + 'get-windows', + '--workspaces=false', + '--ignore-scripts=false', + '--no-audit', + '--no-fund' + ], + { + cwd: recoveryRoot, + env: npmEnv, + stdio: 'inherit' + } + ) + if (rebuildResult.error) { + throw new Error( + `[stage-native-deps] isolated get-windows lifecycle could not start npm: ${rebuildResult.error.message}` + ) + } + if (rebuildResult.status !== 0) { + throw new Error( + `[stage-native-deps] isolated get-windows lifecycle exited with ${rebuildResult.status}` + ) + } + + // Lifecycle execution must not rewrite the dependency authority we just + // attested. + verifyRecoveryGraphAgainstRepository({ + recoveryLock: JSON.parse(readFileSync(recoveryLockPath, 'utf8')), + repositoryLock, + repositoryOverrides: overrides + }) + + let packageRoot + try { + // get-windows does not export package.json; resolve its root entry and + // validate the manifest beside it. + packageRoot = dirname( + require.resolve('get-windows', { + paths: [recoveryRoot] + }) + ) + } catch (error) { + throw new Error( + `[stage-native-deps] isolated get-windows recovery completed without an importable package: ${errorMessage(error)}` + ) + } + + const installedVersion = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8') + ).version + if (installedVersion !== GET_WINDOWS_RECOVERY_VERSION) { + throw new Error( + `[stage-native-deps] isolated get-windows recovery resolved ${installedVersion}; expected ${GET_WINDOWS_RECOVERY_VERSION}` + ) + } + + completed = true + let cleaned = false + return { + packageRoot, + recoveryRoot, + cleanup() { + if (cleaned) { + return true + } + cleaned = cleanupRecoveryRoot(recoveryRoot) + return cleaned + } + } + } finally { + if (!completed) { + cleanupRecoveryRoot(recoveryRoot) + } + } +} + +export function stageGetWindowsWithRecovery({ + platform = process.platform, + arch = process.arch, + hostPlatform = process.platform, + hostArch = process.arch, + recover = recoverGetWindowsPackage, + stage = stageGetWindows +} = {}) { + try { + return stage({ platform, arch }) + } catch (error) { + if ( + !isMissingGetWindowsPackageError(error) || + !canRecoverGetWindowsPackage({ platform, arch, hostPlatform, hostArch }) + ) { + throw error + } + + console.warn( + `[stage-native-deps] get-windows package root is missing or corrupt for ${platform}-${arch}; ` + + `recovering exact ${GET_WINDOWS_RECOVERY_VERSION} in an isolated npm prefix` + ) + const recovery = recover({ platform, arch }) + + try { + return stage({ + platform, + arch, + resolveRoot: () => recovery.packageRoot + }) + } finally { + recovery.cleanup() + } + } +} + +export function stageNativeDeps({ platform = process.platform, arch = process.arch } = {}) { + const nodePty = stageNodePty({ platform, arch }) + const getWindows = stageGetWindowsWithRecovery({ platform, arch }) + return { getWindows, nodePty } +} + +if (isMain(import.meta.url)) { + const [platform, arch] = process.argv.slice(2) + stageNativeDeps({ platform, arch }) +} diff --git a/apps/desktop/scripts/stage-native-deps-recovery.test.mjs b/apps/desktop/scripts/stage-native-deps-recovery.test.mjs new file mode 100644 index 000000000000..cfc40261aa6e --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps-recovery.test.mjs @@ -0,0 +1,405 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'node:test' + +import { + GET_WINDOWS_MISSING_ROOT_MARKER, + GET_WINDOWS_RECOVERY_VERSION, + canRecoverGetWindowsPackage, + committedGetWindowsClosure, + recoverGetWindowsPackage, + stageGetWindowsWithRecovery, + verifyRecoveryGraphAgainstRepository +} from './stage-native-deps-recovery.mjs' + +function tempParent() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-get-windows-recovery-test-')) +} + +function packageEntry(version, name) { + return { + version, + resolved: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, + integrity: `sha512-${name}-${version}` + } +} + +function writeRepositoryAuthority(root) { + const overrides = { tar: '7.5.22' } + const getWindows = { + ...packageEntry(GET_WINDOWS_RECOVERY_VERSION, 'get-windows'), + optionalDependencies: { + 'node-gyp': '^10.2.0' + } + } + const nodeGyp = { + ...packageEntry('10.3.1', 'node-gyp'), + dependencies: { + tar: '^6.2.1' + } + } + const tar = packageEntry('7.5.22', 'tar') + const repositoryLock = { + lockfileVersion: 3, + packages: { + '': {}, + 'node_modules/get-windows': getWindows, + 'node_modules/get-windows/node_modules/node-gyp': nodeGyp, + 'node_modules/tar': tar + } + } + + fs.mkdirSync(root, { recursive: true }) + fs.writeFileSync( + path.join(root, 'package.json'), + `${JSON.stringify({ name: 'fixture', private: true, overrides }, null, 2)}\n` + ) + fs.writeFileSync( + path.join(root, 'package-lock.json'), + `${JSON.stringify(repositoryLock, null, 2)}\n` + ) + return { overrides, repositoryLock } +} + +function recoveryLockFrom(repositoryLock, mutate = undefined) { + const packages = structuredClone(repositoryLock.packages) + packages[''] = { + name: 'hermes-get-windows-recovery', + version: '0.0.0', + dependencies: { 'get-windows': GET_WINDOWS_RECOVERY_VERSION } + } + if (mutate) { + mutate(packages) + } + return { lockfileVersion: 3, packages } +} + +function materializeRecoveredPackage(cwd) { + const packageRoot = path.join(cwd, 'node_modules', 'get-windows') + fs.mkdirSync(path.join(packageRoot, 'lib'), { recursive: true }) + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: 'get-windows', version: GET_WINDOWS_RECOVERY_VERSION }) + ) + fs.writeFileSync(path.join(packageRoot, 'index.js'), 'module.exports = {}\n') +} + +test('committed closure includes only dependency identities reachable from get-windows', () => { + const parent = tempParent() + try { + const { repositoryLock } = writeRepositoryAuthority(path.join(parent, 'repo')) + repositoryLock.packages['node_modules/unrelated'] = packageEntry('1.0.0', 'unrelated') + assert.deepEqual( + [...committedGetWindowsClosure(repositoryLock)].sort(), + [ + 'node_modules/get-windows', + 'node_modules/get-windows/node_modules/node-gyp', + 'node_modules/tar' + ] + ) + } finally { + fs.rmSync(parent, { force: true, recursive: true }) + } +}) + +test('isolated recovery materializes with scripts disabled, proves the lock graph, then rebuilds get-windows', () => { + const parent = tempParent() + const repositoryRoot = path.join(parent, 'repo') + try { + const { overrides, repositoryLock } = writeRepositoryAuthority(repositoryRoot) + const invocations = [] + + const recovery = recoverGetWindowsPackage({ + arch: 'x64', + npmExecPath: '/fake/npm-cli.js', + platform: 'win32', + repositoryRoot, + tempParent: parent, + run(command, args, options) { + invocations.push({ args: [...args], command, options }) + if (args[1] === 'install') { + materializeRecoveredPackage(options.cwd) + fs.writeFileSync( + path.join(options.cwd, 'package-lock.json'), + `${JSON.stringify(recoveryLockFrom(repositoryLock), null, 2)}\n` + ) + return { status: 0 } + } + if (args[1] === 'rebuild') { + return { status: 0 } + } + throw new Error(`unexpected npm action: ${args[1]}`) + } + }) + + assert.equal(invocations.length, 2) + const install = invocations[0] + const rebuild = invocations[1] + + assert.equal(install.command, process.execPath) + assert.equal(install.args[0], '/fake/npm-cli.js') + assert.equal(install.args[1], 'install') + assert.ok(install.args.includes('--workspaces=false')) + assert.ok(install.args.includes('--include=optional')) + assert.ok(install.args.includes('--ignore-scripts=true')) + assert.ok(install.args.includes('--package-lock=true')) + assert.equal(install.args.includes('--ignore-scripts=false'), false) + + assert.equal(rebuild.args[1], 'rebuild') + assert.equal(rebuild.args[2], 'get-windows') + assert.ok(rebuild.args.includes('--ignore-scripts=false')) + + assert.equal(install.options.cwd, recovery.recoveryRoot) + assert.equal(install.options.env.npm_config_platform, 'win32') + assert.equal(install.options.env.npm_config_arch, 'x64') + + const manifest = JSON.parse( + fs.readFileSync(path.join(recovery.recoveryRoot, 'package.json'), 'utf8') + ) + assert.deepEqual(manifest.dependencies, { + 'get-windows': GET_WINDOWS_RECOVERY_VERSION + }) + assert.deepEqual(manifest.overrides, overrides) + assert.deepEqual(manifest.allowScripts, { + [`get-windows@${GET_WINDOWS_RECOVERY_VERSION}`]: true + }) + assert.equal( + recovery.packageRoot, + path.join(recovery.recoveryRoot, 'node_modules', 'get-windows') + ) + assert.ok(fs.existsSync(recovery.recoveryRoot)) + + assert.equal(recovery.cleanup(), true) + assert.equal(recovery.cleanup(), true) + assert.equal(fs.existsSync(recovery.recoveryRoot), false) + } finally { + fs.rmSync(parent, { force: true, recursive: true }) + } +}) + +test('drifted transitive resolution is rejected before any lifecycle script executes', () => { + const parent = tempParent() + const repositoryRoot = path.join(parent, 'repo') + try { + const { repositoryLock } = writeRepositoryAuthority(repositoryRoot) + const actions = [] + + assert.throws( + () => + recoverGetWindowsPackage({ + npmExecPath: '/fake/npm-cli.js', + repositoryRoot, + tempParent: parent, + run(_command, args, options) { + actions.push(args[1]) + if (args[1] !== 'install') { + throw new Error('lifecycle must not execute after provenance failure') + } + materializeRecoveredPackage(options.cwd) + const drifted = recoveryLockFrom(repositoryLock, packages => { + packages['node_modules/get-windows/node_modules/node-gyp'] = { + ...packages['node_modules/get-windows/node_modules/node-gyp'], + resolved: 'https://registry.example.invalid/node-gyp-10.3.1.tgz', + integrity: 'sha512-drifted' + } + }) + fs.writeFileSync( + path.join(options.cwd, 'package-lock.json'), + `${JSON.stringify(drifted, null, 2)}\n` + ) + return { status: 0 } + } + }), + /not present in the committed get-windows dependency closure/ + ) + assert.deepEqual(actions, ['install']) + assert.deepEqual( + fs.readdirSync(parent).filter(name => name.startsWith('hermes-get-windows-')), + [] + ) + } finally { + fs.rmSync(parent, { force: true, recursive: true }) + } +}) + +test('repository tar override is part of recovery authority and cannot drift', () => { + const parent = tempParent() + try { + const { overrides, repositoryLock } = writeRepositoryAuthority(path.join(parent, 'repo')) + const drifted = recoveryLockFrom(repositoryLock, packages => { + packages['node_modules/tar'] = packageEntry('6.2.1', 'tar') + }) + + assert.throws( + () => + verifyRecoveryGraphAgainstRepository({ + recoveryLock: drifted, + repositoryLock, + repositoryOverrides: overrides + }), + /tar override|committed get-windows dependency closure/ + ) + + assert.equal( + verifyRecoveryGraphAgainstRepository({ + recoveryLock: recoveryLockFrom(repositoryLock), + repositoryLock, + repositoryOverrides: overrides + }), + true + ) + } finally { + fs.rmSync(parent, { force: true, recursive: true }) + } +}) + +test('failed isolated install is removed and cannot poison a later update', () => { + const parent = tempParent() + const repositoryRoot = path.join(parent, 'repo') + try { + writeRepositoryAuthority(repositoryRoot) + assert.throws( + () => + recoverGetWindowsPackage({ + npmExecPath: '/fake/npm-cli.js', + repositoryRoot, + run: () => ({ status: 1 }), + tempParent: parent + }), + /isolated get-windows recovery install exited with 1/ + ) + assert.deepEqual( + fs.readdirSync(parent).filter(name => name.startsWith('hermes-get-windows-')), + [] + ) + } finally { + fs.rmSync(parent, { force: true, recursive: true }) + } +}) + +test('supported native Windows staging retries with the recovered package root', () => { + const calls = [] + let cleaned = false + + const result = stageGetWindowsWithRecovery({ + arch: 'x64', + hostArch: 'x64', + hostPlatform: 'win32', + platform: 'win32', + recover: () => ({ + cleanup() { + cleaned = true + }, + packageRoot: 'C:\\Temp\\isolated\\node_modules\\get-windows' + }), + stage(options) { + calls.push(options) + if (calls.length === 1) { + throw new Error(`${GET_WINDOWS_MISSING_ROOT_MARKER}win32-x64 native payload`) + } + assert.equal( + options.resolveRoot(), + 'C:\\Temp\\isolated\\node_modules\\get-windows' + ) + return 'staged' + } + }) + + assert.equal(result, 'staged') + assert.equal(calls.length, 2) + assert.equal(cleaned, true) +}) + +test('recovered temporary dependency is removed when the second staging attempt fails', () => { + let calls = 0 + let cleaned = false + + assert.throws( + () => + stageGetWindowsWithRecovery({ + arch: 'x64', + hostArch: 'x64', + hostPlatform: 'win32', + platform: 'win32', + recover: () => ({ + cleanup() { + cleaned = true + }, + packageRoot: 'C:\\Temp\\isolated\\node_modules\\get-windows' + }), + stage() { + calls += 1 + if (calls === 1) { + throw new Error(`${GET_WINDOWS_MISSING_ROOT_MARKER}win32-x64 native payload`) + } + throw new Error('recovered package has no binding') + } + }), + /recovered package has no binding/ + ) + assert.equal(cleaned, true) +}) + +test('unrelated staging failures are never converted into dependency recovery', () => { + let recovered = false + + assert.throws( + () => + stageGetWindowsWithRecovery({ + recover() { + recovered = true + throw new Error('should not run') + }, + stage() { + throw new Error('native binary platform mismatch') + } + }), + /native binary platform mismatch/ + ) + assert.equal(recovered, false) +}) + +test('cross-platform and unsupported Windows architecture requests remain fail-closed', () => { + assert.equal( + canRecoverGetWindowsPackage({ + arch: 'x64', + hostArch: 'x64', + hostPlatform: 'linux', + platform: 'win32' + }), + false + ) + assert.equal( + canRecoverGetWindowsPackage({ + arch: 'arm64', + hostArch: 'arm64', + hostPlatform: 'win32', + platform: 'win32' + }), + false + ) + assert.equal( + canRecoverGetWindowsPackage({ + arch: 'x64', + hostArch: 'x64', + hostPlatform: 'win32', + platform: 'win32' + }), + true + ) +}) + +test('Desktop build and electron-builder hook both consume the recovery owner', () => { + const manifest = JSON.parse( + fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) + + assert.match(manifest.scripts.build, /stage-native-deps-recovery\.mjs$/) + assert.match( + manifest.scripts['check:test:desktop:all'], + /stage-native-deps-recovery\.test\.mjs/ + ) + assert.equal(manifest.build.beforePack, 'scripts/before-pack-recovery.mjs') +}) diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index 70835d91f03a..1e1c2e94809e 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -21,7 +21,13 @@ const electronNative: TestProjectConfiguration = { name: 'electron', environment: 'node', include: ['electron/**/*.test.ts', 'scripts/**.test.{ts,mjs}'], - exclude: ['scripts/run-short-session-hang-repro.test.mjs'] + exclude: [ + 'scripts/run-short-session-hang-repro.test.mjs', + // This suite is wired explicitly through `node --test` in + // check:test:desktop:all. Let one runner own it instead of making Node + // and Vitest reject each other's registration APIs. + 'scripts/stage-native-deps-recovery.test.mjs' + ] } } diff --git a/tests/hermes_cli/test_desktop_pack_transaction_windows.py b/tests/hermes_cli/test_desktop_pack_transaction_windows.py new file mode 100644 index 000000000000..5ff2792653aa --- /dev/null +++ b/tests/hermes_cli/test_desktop_pack_transaction_windows.py @@ -0,0 +1,217 @@ +"""Native-Windows witness for the Desktop package rollback transaction. + +The JS builder boundary and the Python launchability gate are intentionally +separate authorities. These tests run only on a real Windows runner and drive +the same on-disk ``win-unpacked`` / ``win-unpacked.bak`` generations through +both layers. No Electron or npm mock stands in for the filesystem transaction. +""" + +from __future__ import annotations + +import json +import shutil +import struct +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli import main as cli_main + +PE_AMD64 = 0x8664 +PE_ARM64 = 0xAA64 + + +def _make_pe( + path: Path, + machine: int, + *, + marker: bytes, + truncate_to: int | None = None, +) -> Path: + """Write a minimal PE whose section table is accepted by both verifiers.""" + buf = bytearray(0x400) + buf[0:2] = b"MZ" + struct.pack_into(" dict: + node = shutil.which("node") + assert node is not None, "Windows transaction witness requires Node on PATH" + + transaction_module = ( + Path(__file__).resolve().parents[2] + / "apps" + / "desktop" + / "scripts" + / "desktop-pack-transaction.mjs" + ) + assert transaction_module.is_file() + + script = r""" +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const [modulePath, releaseDir, sessionId] = process.argv.slice(1) +const { + settleDesktopPack, + writeRollbackSession +} = await import(pathToFileURL(modulePath).href) + +const backupDir = path.join(releaseDir, 'win-unpacked.bak') +writeRollbackSession(backupDir, sessionId) +const result = settleDesktopPack({ + releaseDir, + builderSucceeded: true, + sessionId +}) +process.stdout.write(JSON.stringify(result)) +""" + completed = subprocess.run( + [ + node, + "--input-type=module", + "-e", + script, + str(transaction_module), + str(release_dir), + session_id, + ], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, ( + f"Node settlement failed with {completed.returncode}\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + return json.loads(completed.stdout) + + +@pytest.mark.windows_only +def test_valid_candidate_crosses_js_settlement_and_python_gate(tmp_path): + """A plausible replacement keeps rollback authority until Python accepts it.""" + desktop_dir = tmp_path / "apps" / "desktop" + release_dir = desktop_dir / "release" + candidate = _make_pe( + release_dir / "win-unpacked" / "Hermes.exe", + PE_AMD64, + marker=b"accepted-candidate", + ) + backup = _make_pe( + release_dir / "win-unpacked.bak" / "Hermes.exe", + PE_AMD64, + marker=b"previous-generation", + ) + candidate_bytes = candidate.read_bytes() + backup_bytes = backup.read_bytes() + + settlement = _settle_apparent_builder_success(release_dir, "accepted-session") + + assert settlement["ok"] is True + assert len(settlement["retained"]) == 1 + assert settlement["restored"] == [] + assert candidate.read_bytes() == candidate_bytes + assert backup.read_bytes() == backup_bytes + + with patch( + "hermes_cli.main._expected_windows_pe_machines", + return_value={PE_AMD64}, + ): + verified, rolled_back = cli_main._ensure_desktop_exe_launchable( + desktop_dir, candidate + ) + + assert verified == candidate + assert rolled_back is False + assert candidate.read_bytes() == candidate_bytes + # The accepted generation cannot retroactively destroy rollback material; + # the next package generation replaces it transactionally. + assert backup.read_bytes() == backup_bytes + + +@pytest.mark.windows_only +def test_wrong_arch_candidate_is_rolled_back_by_python_after_js_retains_it(tmp_path): + """The exact former blocker: a structurally valid wrong-arch PE reaches Python.""" + desktop_dir = tmp_path / "apps" / "desktop" + release_dir = desktop_dir / "release" + candidate = _make_pe( + release_dir / "win-unpacked" / "Hermes.exe", + PE_ARM64, + marker=b"wrong-arch-candidate", + ) + backup = _make_pe( + release_dir / "win-unpacked.bak" / "Hermes.exe", + PE_AMD64, + marker=b"known-good-backup", + ) + corrupt_bytes = candidate.read_bytes() + backup_bytes = backup.read_bytes() + + settlement = _settle_apparent_builder_success(release_dir, "rollback-session") + + assert settlement["ok"] is True + assert len(settlement["retained"]) == 1 + assert settlement["restored"] == [] + assert backup.exists() + + with ( + patch( + "hermes_cli.main._expected_windows_pe_machines", + return_value={PE_AMD64}, + ), + patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), + patch( + "hermes_cli.main._desktop_stamp_path", + return_value=tmp_path / "desktop-build-stamp.json", + ), + ): + verified, rolled_back = cli_main._ensure_desktop_exe_launchable( + desktop_dir, candidate + ) + + assert verified == candidate + assert rolled_back is True + assert candidate.read_bytes() == backup_bytes + assert not backup.exists() + assert ( + release_dir / "win-unpacked.corrupt" / "Hermes.exe" + ).read_bytes() == corrupt_bytes + + +@pytest.mark.windows_only +def test_structurally_truncated_candidate_restores_before_python_gate(tmp_path): + """A false builder success cannot leave an incomplete PE as the active app.""" + release_dir = tmp_path / "apps" / "desktop" / "release" + candidate = _make_pe( + release_dir / "win-unpacked" / "Hermes.exe", + PE_AMD64, + marker=b"truncated-candidate", + truncate_to=0x300, + ) + backup = _make_pe( + release_dir / "win-unpacked.bak" / "Hermes.exe", + PE_AMD64, + marker=b"known-good-backup", + ) + backup_bytes = backup.read_bytes() + + settlement = _settle_apparent_builder_success(release_dir, "truncated-session") + + assert settlement["ok"] is False + assert len(settlement["restored"]) == 1 + assert settlement["retained"] == [] + assert settlement["failures"] + assert candidate.read_bytes() == backup_bytes + assert not backup.exists()