Skip to content

Commit f70a493

Browse files
author
Tomasz Iniewicz
committed
fix(webui): verify mac-arm64 bundled uv via codesign, not a SHA256 pin
The BUNDLED_UV_SHA256["mac-arm64"] pin captured a post-codesign digest, but electron-builder ad-hoc-signs the bundled uv on every CI build (no Developer ID cert configured), and ad-hoc codesign output depends on the codesign/Xcode toolchain baked into the GitHub-hosted macos-latest runner image. That image floats between macos-15-arm64 and macos-26-arm64, producing two different digests for byte-identical source — confirmed across dmg-structural-smoke runs on PRs #1861, #1886, and #1926. A fixed pin is therefore not deterministic on macOS: it fails roughly half of CI runs, and a shipped DMG whose signing output mismatches the baked-in pin would hard-fail ensureUv() on a user's first launch. Replace the fixed digest with `codesign --verify --strict` in both ensureUv() (runtime) and the dmg-structural-smoke helper (CI), matching the check to what's actually deterministic: the signature's structural validity, not its exact bytes. Ad-hoc signatures pass this check; tampered or corrupted binaries still fail it, preserving the fail-loud guarantee. linux-x64/win-x64 keep their existing SHA256 pins, which are genuinely deterministic there (no post-build re-signing).
1 parent ca95fec commit f70a493

4 files changed

Lines changed: 197 additions & 85 deletions

File tree

.github/workflows/build-installers.yml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,14 @@ jobs:
299299
cp "${tmpdir}/uv-aarch64-apple-darwin/uv" "${DEST_DIR}/uv"
300300
chmod 0755 "${DEST_DIR}/uv"
301301
"${DEST_DIR}/uv" --version
302-
# Echo the extracted-binary SHA (pre-codesign). To bump the
303-
# runtime's POST-codesign digest (BUNDLED_UV_SHA256[mac-arm64]),
304-
# run CI and copy the value from the dmg-structural-smoke output.
302+
# Echo the extracted-binary SHA (pre-codesign) for build logs only —
303+
# there is no runtime pin to update for mac-arm64. Unlike
304+
# linux-x64/win-x64, mac-arm64's BUNDLED_UV_SHA256 entry was removed:
305+
# electron-builder re-signs this binary during packaging (ad-hoc
306+
# identity=- on every CI build today), and ad-hoc codesign output is
307+
# not deterministic across GitHub-hosted macos-latest runner image
308+
# rollovers. ensureUv() and dmg-structural-smoke both verify this
309+
# binary via `codesign --verify --strict` instead of a fixed digest.
305310
shasum -a 256 "${DEST_DIR}/uv"
306311
307312
- name: Build frontend (Vite)
@@ -762,8 +767,12 @@ jobs:
762767
# Mirrors appimage-structural-smoke for the macOS DMG. Catches the
763768
# failure mode that bit v0.17.5: a darwin-arm64 install that hard-fails
764769
# in ensure-uv on first launch because the bundled uv either was never
765-
# shipped or has the wrong SHA256 against BUNDLED_UV_SHA256[mac-arm64]
766-
# in backend-installer.cjs.
770+
# shipped or fails `codesign --verify --strict` in backend-installer.cjs.
771+
# (Prior to the mac-arm64 pin removal, this compared against a fixed
772+
# BUNDLED_UV_SHA256[mac-arm64] digest — retired because ad-hoc codesign
773+
# output is not deterministic across GitHub-hosted macos-latest runner
774+
# image rollovers; see the BUNDLED_UV_SHA256 comment in
775+
# backend-installer.cjs.)
767776
#
768777
# MUST be present in build-complete `needs:` below — without that
769778
# wiring, a failing DMG smoke does not block release-readiness.
@@ -793,7 +802,7 @@ jobs:
793802
echo "Found DMG: ${DMG}"
794803
echo "dmg=${DMG}" >> "$GITHUB_OUTPUT"
795804
796-
- name: Structural smoke (uv binary, mode, sha256, --version)
805+
- name: Structural smoke (uv binary, mode, codesign, --version)
797806
env:
798807
GAIA_DMG: ${{ steps.locate.outputs.dmg }}
799808
run: node --test tests/electron/dmg-smoke.test.mjs

src/gaia/apps/webui/services/backend-installer.cjs

Lines changed: 141 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
99101
const BUNDLED_UV_VERSION = "0.5.14";
100102
const 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

111116
const 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
*/
954986
async 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();

tests/electron/_helpers/installer-smoke.mjs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
// 1. The in-resources path layout for the bundled `uv` binary
88
// (mirrors electron-builder.yml `extraResources.to: vendor/uv`).
99
// 2. Parsing BUNDLED_UV_SHA256 out of backend-installer.cjs.
10-
// 3. The full existence + executable-bit + SHA256 check.
10+
// 3. The full existence + executable-bit + verification check — SHA256
11+
// pin on linux-x64/win-x64, `codesign --verify --strict` on mac-arm64
12+
// (see the BUNDLED_UV_SHA256 comment in backend-installer.cjs for why
13+
// mac-arm64 cannot use a fixed digest pin).
1114
//
1215
// Consumed by tests/electron/appimage-smoke.test.mjs and
1316
// tests/electron/dmg-smoke.test.mjs. A future NSIS smoke test should
@@ -18,6 +21,7 @@ import crypto from "node:crypto";
1821
import fs from "node:fs";
1922
import path from "node:path";
2023
import { fileURLToPath } from "node:url";
24+
import { spawnSync } from "node:child_process";
2125

2226
// Mirrors electron-builder.yml `extraResources.to: vendor/uv` — the single
2327
// source of truth for the in-resources layout. If electron-builder.yml
@@ -69,12 +73,16 @@ export function parseBundledUvSha(installerCjsPath, platformKey) {
6973
}
7074

7175
/**
72-
* Existence + executable-bit (POSIX only) + SHA256-vs-pin check.
76+
* Existence + executable-bit (POSIX only) + verification check.
7377
*
74-
* Catches the failure mode that bit issue #849 and motivated #941:
75-
* a packaged binary whose SHA does not match the pin in
76-
* BUNDLED_UV_SHA256, which `ensureUv()` would reject at runtime with
77-
* a hard SHA256 mismatch error on the user's first launch.
78+
* mac-arm64 verifies via `codesign --verify --strict` — matching
79+
* ensureUv()'s runtime check (see the BUNDLED_UV_SHA256 comment in
80+
* backend-installer.cjs: ad-hoc codesign output is not deterministic
81+
* across CI runner images, so mac-arm64 has no fixed digest to pin
82+
* against). Other platforms compare SHA256 against the pin in
83+
* BUNDLED_UV_SHA256, catching the failure mode that bit issue #849 and
84+
* motivated #941: a packaged binary whose SHA does not match the pin,
85+
* which `ensureUv()` would reject at runtime on the user's first launch.
7886
*
7987
* @param {string} uvPath absolute path to packaged `uv` binary
8088
* @param {string} platformKey e.g. "mac-arm64"
@@ -91,6 +99,21 @@ export function assertUvBinary(uvPath, platformKey, installerCjsPath) {
9199
`uv binary should be executable; mode=${(st.mode & 0o777).toString(8)}`,
92100
);
93101
}
102+
103+
if (platformKey === "mac-arm64") {
104+
const result = spawnSync("codesign", ["--verify", "--strict", uvPath], {
105+
encoding: "utf8",
106+
});
107+
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
108+
assert.equal(
109+
result.status,
110+
0,
111+
`bundled mac-arm64 uv failed \`codesign --verify --strict\`; ` +
112+
`ensureUv() will reject this at runtime. Output: ${output || "(none)"}`,
113+
);
114+
return;
115+
}
116+
94117
const expected = parseBundledUvSha(installerCjsPath, platformKey);
95118
const actual = crypto
96119
.createHash("sha256")

0 commit comments

Comments
 (0)