@@ -77,35 +77,40 @@ const INSTALL_RETRY_BACKOFF_MS = 3000;
7777// `curl | sh` path is retained only as an unpackaged-dev fallback so
7878// contributors running from source keep working.
7979//
80- // When bumping uv, update BOTH :
80+ // When bumping uv, update:
8181// - .github/workflows/build-installers.yml (tarball .tar.gz SHA256 — archive)
82- // - BUNDLED_UV_SHA256 below (extracted ELF binary SHA256)
83- // These are two different digests: the workflow verifies the downloaded
84- // archive against upstream's published .sha256, then extracts the `uv` binary
85- // which is what `ensureUv()` hashes at runtime.
82+ // - BUNDLED_UV_SHA256 below (extracted binary SHA256, linux-x64/win-x64 only)
8683//
87- // Currently pinned: uv v0.5.14 linux-x64, mac-arm64.
88- // (win-x64 deferred to a follow-up issue — its SHA must ship together with an
89- // NSIS structural-smoke verifier, not on its own; see the #849 lesson.)
90- //
91- // IMPORTANT: per-platform SHA origin differs:
92- // - linux-x64: raw extracted-from-tarball digest (no post-build modification).
93- // - mac-arm64: POST-CODESIGN digest. electron-builder code-signs the bundled
94- // uv during packaging, so this hash matches what ensureUv() sees
95- // at runtime, NOT the upstream tarball. Bumping this pin means
96- // running the CI build, then copying the SHA from the
97- // dmg-structural-smoke failure message — never from `shasum`
98- // against the freshly downloaded tarball.
84+ // IMPORTANT: per-platform verification strategy differs:
85+ // - linux-x64 / win-x64: BUNDLED_UV_SHA256 pins the raw extracted-binary
86+ // digest (no post-build modification) — deterministic across CI runs.
87+ // - mac-arm64: NOT pinned here. electron-builder code-signs the bundled uv
88+ // during packaging (ad-hoc `identity=-` when no Developer ID cert is
89+ // configured, which is every CI build today), and ad-hoc codesign output
90+ // depends on the codesign/Xcode toolchain baked into the GitHub-hosted
91+ // `macos-latest` runner image — which floats and is NOT reproducible
92+ // across CI runs (observed: macos-15-arm64 vs macos-26-arm64 images
93+ // produced two different digests for byte-identical source). A fixed
94+ // SHA256 pin is therefore not deterministic on macOS and would fail
95+ // ~half of CI runs and brick first-launch on user machines whenever the
96+ // runner image rolls. Instead, ensureUv() and the dmg-structural-smoke
97+ // test both run `codesign --verify --strict` against the bundled binary
98+ // — this validates the on-disk signature is intact/untampered without
99+ // depending on the exact signing bytes, and still fails loud on
100+ // corruption or an actually-invalid signature.
99101const BUNDLED_UV_VERSION = "0.5.14" ;
100102const BUNDLED_UV_SHA256 = {
101103 "linux-x64" : "0e05d828b5708e8a927724124db3746396afddad6273c47283d7c562dc795bd6" ,
102104 // The Windows extracted uv.exe SHA is populated by CI during the
103105 // build step. The placeholder MUST be replaced in CI before packaging
104106 // so runtime verification remains strict.
105107 "win-x64" : "055d55eec85a91cfb5e9c8bc7f6463f9883866796c5bcb205fbcdfed9c088c88" ,
106- // mac-arm64: POST-codesign digest. CI should populate this value when
107- // packaging the macOS DMG and running the dmg-structural-smoke job.
108- "mac-arm64" : "6099aa8cd701f0c81227ee30c304777ce151e4d47c53a75ce53cd2243448d8c8" ,
108+ // mac-arm64: intentionally absent. See the comment block above — the
109+ // post-codesign digest is not deterministic across CI runner images, so
110+ // mac-arm64 is verified via codesignVerify() (identity/signature validity)
111+ // instead of a fixed SHA256 pin. bundledUvPlatformKey() still returns
112+ // "mac-arm64"; callers must not treat a missing entry here as "unpinned
113+ // platform" for darwin — see ensureUv()/installBundledUv().
109114} ;
110115
111116const MANAGED_UV_DIR = path . join ( GAIA_HOME , "bin" ) ;
@@ -924,6 +929,27 @@ function sha256File(filePath) {
924929 } ) ;
925930}
926931
932+ /**
933+ * Verify a macOS binary's code signature is structurally intact via
934+ * `codesign --verify --strict`. Used in place of a fixed SHA256 pin for
935+ * mac-arm64 (see the BUNDLED_UV_SHA256 comment) — ad-hoc-signed binaries
936+ * (identity=-, which is every CI build today) pass this check, but a
937+ * corrupted, tampered, or actually-unsigned binary fails it. This does NOT
938+ * depend on the specific bytes any given codesign/Xcode toolchain produces,
939+ * so it is stable across GitHub-hosted runner image rollovers where a fixed
940+ * digest pin is not.
941+ *
942+ * @param {string } binPath
943+ * @returns {{ ok: boolean, output: string } }
944+ */
945+ function codesignVerify ( binPath ) {
946+ const result = spawnSync ( "codesign" , [ "--verify" , "--strict" , binPath ] , {
947+ encoding : "utf8" ,
948+ } ) ;
949+ const output = `${ result . stdout || "" } ${ result . stderr || "" } ` . trim ( ) ;
950+ return { ok : result . status === 0 , output } ;
951+ }
952+
927953/**
928954 * Resolve the bundled uv binary path inside the Electron resources dir.
929955 * Returns null if this isn't an Electron-packaged runtime (no
@@ -945,15 +971,22 @@ function findBundledUvResource() {
945971}
946972
947973/**
948- * Atomically install the bundled uv into ~/.gaia/bin/uv after verifying
949- * its SHA256 against BUNDLED_UV_SHA256. Returns the installed path.
974+ * Atomically install the bundled uv into ~/.gaia/bin/uv after verifying it.
975+ * Returns the installed path.
950976 *
951- * Writes to `uv.tmp-<pid>-<rand>` with mode 0o700, verifies hash,
952- * `chmod +x`, then `fs.rename()` (atomic on same filesystem).
977+ * Verification strategy differs by platform (see the BUNDLED_UV_SHA256
978+ * comment): mac-arm64 uses `codesign --verify --strict` (darwin only —
979+ * post-codesign SHA256 is not deterministic across CI runner images);
980+ * linux-x64 / win-x64 use the SHA256 pin in BUNDLED_UV_SHA256, which IS
981+ * deterministic for those platforms (no post-build re-signing).
982+ *
983+ * Writes to `uv.tmp-<pid>-<rand>` with mode 0o700, verifies, `chmod +x`,
984+ * then `fs.rename()` (atomic on same filesystem).
953985 */
954986async function installBundledUv ( sourcePath , platformKey ) {
987+ const isDarwin = platformKey === "mac-arm64" ;
955988 const expected = BUNDLED_UV_SHA256 [ platformKey ] ;
956- if ( ! expected || expected . startsWith ( "<" ) ) {
989+ if ( ! isDarwin && ( ! expected || expected . startsWith ( "<" ) ) ) {
957990 // Enforce strict verification: builds MUST populate the expected SHA
958991 // for packaged binaries. Failing fast prevents shipping an unverified
959992 // uv binary which would be a supply-chain regression.
@@ -989,18 +1022,39 @@ async function installBundledUv(sourcePath, platformKey) {
9891022 rs . pipe ( ws ) ;
9901023 } ) ;
9911024
992- let actual ;
993- try {
994- actual = await sha256File ( tmpPath ) ;
995- } catch ( err ) {
996- try { fs . unlinkSync ( tmpPath ) ; } catch { /* ignore */ }
997- throw new InstallError (
998- `Could not hash copied uv binary: ${ err . message } ` ,
999- { stage : STAGES . ENSURE_UV }
1000- ) ;
1001- }
1025+ if ( isDarwin ) {
1026+ // chmod BEFORE codesign --verify: codesign needs the execute bit to
1027+ // resolve the binary's designated requirement on some toolchains.
1028+ try {
1029+ fs . chmodSync ( tmpPath , 0o700 ) ;
1030+ } catch ( err ) {
1031+ log ( `Warning: chmod on tmp uv failed: ${ err . message } ` ) ;
1032+ }
1033+ const { ok, output } = codesignVerify ( tmpPath ) ;
1034+ if ( ! ok ) {
1035+ try { fs . unlinkSync ( tmpPath ) ; } catch { /* ignore */ }
1036+ throw new InstallError (
1037+ `Bundled uv failed code signature verification (codesign --verify --strict): ${ output || "no output" } ` ,
1038+ {
1039+ stage : STAGES . ENSURE_UV ,
1040+ suggestion :
1041+ "The AppImage/installer may be corrupt. Re-download from https://amd-gaia.ai and try again." ,
1042+ }
1043+ ) ;
1044+ }
1045+ log ( "Bundled uv passed codesign --verify --strict" ) ;
1046+ } else {
1047+ let actual ;
1048+ try {
1049+ actual = await sha256File ( tmpPath ) ;
1050+ } catch ( err ) {
1051+ try { fs . unlinkSync ( tmpPath ) ; } catch { /* ignore */ }
1052+ throw new InstallError (
1053+ `Could not hash copied uv binary: ${ err . message } ` ,
1054+ { stage : STAGES . ENSURE_UV }
1055+ ) ;
1056+ }
10021057
1003- if ( expected ) {
10041058 if ( actual !== expected ) {
10051059 try { fs . unlinkSync ( tmpPath ) ; } catch { /* ignore */ }
10061060 throw new InstallError (
@@ -1012,14 +1066,12 @@ async function installBundledUv(sourcePath, platformKey) {
10121066 }
10131067 ) ;
10141068 }
1015- } else {
1016- log ( "No expected SHA registered for bundled uv; installed binary will not be verified locally." ) ;
1017- }
10181069
1019- try {
1020- if ( ! IS_WINDOWS ) fs . chmodSync ( tmpPath , 0o700 ) ;
1021- } catch ( err ) {
1022- log ( `Warning: chmod on tmp uv failed: ${ err . message } ` ) ;
1070+ try {
1071+ if ( ! IS_WINDOWS ) fs . chmodSync ( tmpPath , 0o700 ) ;
1072+ } catch ( err ) {
1073+ log ( `Warning: chmod on tmp uv failed: ${ err . message } ` ) ;
1074+ }
10231075 }
10241076
10251077 try {
@@ -1057,9 +1109,11 @@ function addManagedBinToPath() {
10571109
10581110/**
10591111 * Ensure `uv` is available. Preference order (per issue #782 / T3):
1060- * 1. Managed copy at ~/.gaia/bin/uv with matching SHA256 (warm-install fast path).
1112+ * 1. Managed copy at ~/.gaia/bin/uv already verified (warm-install fast path):
1113+ * SHA256 pin on linux-x64/win-x64, `codesign --verify --strict` on mac-arm64.
10611114 * 2. Bundled binary in process.resourcesPath/vendor/uv/<platform>/uv:
1062- * copy atomically to ~/.gaia/bin/uv with SHA256 verification.
1115+ * copy atomically to ~/.gaia/bin/uv with the same platform-appropriate
1116+ * verification.
10631117 * 3. DEV-ONLY fallback (app.isPackaged === false OR no resourcesPath):
10641118 * the original `curl | sh` from astral.sh. Not a shipped-user path.
10651119 * 4. System `uv` on PATH (last resort — unverified version).
@@ -1071,10 +1125,20 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
10711125 report ( STAGES . ENSURE_UV , 0 , "Checking uv (Python package manager)" ) ;
10721126
10731127 const platformKey = bundledUvPlatformKey ( ) ;
1128+ const isDarwin = platformKey === "mac-arm64" ;
10741129 const expectedSha = platformKey ? BUNDLED_UV_SHA256 [ platformKey ] : null ;
10751130
1076- // Fast path: warm install already on disk with correct hash.
1077- if ( expectedSha && fs . existsSync ( MANAGED_UV_BIN ) ) {
1131+ // Fast path: warm install already on disk and still passes verification.
1132+ if ( isDarwin && fs . existsSync ( MANAGED_UV_BIN ) ) {
1133+ const { ok, output } = codesignVerify ( MANAGED_UV_BIN ) ;
1134+ if ( ok ) {
1135+ log ( `Managed uv at ${ MANAGED_UV_BIN } passed codesign --verify --strict — reusing` ) ;
1136+ addManagedBinToPath ( ) ;
1137+ report ( STAGES . ENSURE_UV , 100 , "uv ready (cached)" ) ;
1138+ return ;
1139+ }
1140+ log ( `Managed uv failed codesign verification (${ output || "no output" } ) — replacing` ) ;
1141+ } else if ( expectedSha && fs . existsSync ( MANAGED_UV_BIN ) ) {
10781142 try {
10791143 const actual = await sha256File ( MANAGED_UV_BIN ) ;
10801144 if ( actual === expectedSha ) {
@@ -1097,22 +1161,36 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
10971161 report ( STAGES . ENSURE_UV , 30 , "Installing bundled uv" ) ;
10981162 log ( `Using bundled uv from ${ bundled } ` ) ;
10991163
1100- // Verify the source resource matches the manifest before copying —
1101- // catches AppImage corruption before we touch the user's home.
1102- // Enforce that the packaged build provides an expected SHA for the
1103- // bundled resource. CI replaces the placeholder with the extracted
1104- // binary's SHA during the build; missing/placeholder values are a
1105- // build-time error and are rejected at runtime here.
1106- const srcHash = await sha256File ( bundled ) ;
1107- if ( srcHash !== expectedSha ) {
1108- throw new InstallError (
1109- `Bundled uv resource SHA256 mismatch (expected ${ expectedSha } , got ${ srcHash } ).` ,
1110- {
1111- stage : STAGES . ENSURE_UV ,
1112- suggestion :
1113- "The installer appears to be corrupt. Re-download GAIA from https://amd-gaia.ai and try again." ,
1114- }
1115- ) ;
1164+ // Verify the source resource before copying — catches installer
1165+ // corruption before we touch the user's home. mac-arm64 uses
1166+ // codesign --verify --strict (see BUNDLED_UV_SHA256 comment for why a
1167+ // fixed digest isn't viable there); other platforms use the SHA256 pin,
1168+ // which CI must have populated — a missing/placeholder value is a
1169+ // build-time error, rejected at runtime here.
1170+ if ( isDarwin ) {
1171+ const { ok, output } = codesignVerify ( bundled ) ;
1172+ if ( ! ok ) {
1173+ throw new InstallError (
1174+ `Bundled uv resource failed code signature verification (codesign --verify --strict): ${ output || "no output" } ` ,
1175+ {
1176+ stage : STAGES . ENSURE_UV ,
1177+ suggestion :
1178+ "The installer appears to be corrupt. Re-download GAIA from https://amd-gaia.ai and try again." ,
1179+ }
1180+ ) ;
1181+ }
1182+ } else {
1183+ const srcHash = await sha256File ( bundled ) ;
1184+ if ( srcHash !== expectedSha ) {
1185+ throw new InstallError (
1186+ `Bundled uv resource SHA256 mismatch (expected ${ expectedSha } , got ${ srcHash } ).` ,
1187+ {
1188+ stage : STAGES . ENSURE_UV ,
1189+ suggestion :
1190+ "The installer appears to be corrupt. Re-download GAIA from https://amd-gaia.ai and try again." ,
1191+ }
1192+ ) ;
1193+ }
11161194 }
11171195 await installBundledUv ( bundled , platformKey ) ;
11181196 addManagedBinToPath ( ) ;
0 commit comments