@amd-gaia/agent-ui 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,8 @@ const fs = require("fs");
36
36
  const path = require("path");
37
37
  const os = require("os");
38
38
  const https = require("https");
39
+ const http = require("http");
40
+ const tls = require("tls");
39
41
 
40
42
  // ── Constants ────────────────────────────────────────────────────────────────
41
43
 
@@ -75,25 +77,27 @@ const INSTALL_RETRY_BACKOFF_MS = 3000;
75
77
  // `curl | sh` path is retained only as an unpackaged-dev fallback so
76
78
  // contributors running from source keep working.
77
79
  //
78
- // When bumping uv, update BOTH:
80
+ // When bumping uv, update:
79
81
  // - .github/workflows/build-installers.yml (tarball .tar.gz SHA256 — archive)
80
- // - BUNDLED_UV_SHA256 below (extracted ELF binary SHA256)
81
- // These are two different digests: the workflow verifies the downloaded
82
- // archive against upstream's published .sha256, then extracts the `uv` binary
83
- // which is what `ensureUv()` hashes at runtime.
82
+ // - BUNDLED_UV_SHA256 below (extracted binary SHA256, linux-x64/win-x64 only)
84
83
  //
85
- // Currently pinned: uv v0.5.14 linux-x64, mac-arm64.
86
- // (win-x64 deferred to a follow-up issue — its SHA must ship together with an
87
- // NSIS structural-smoke verifier, not on its own; see the #849 lesson.)
88
- //
89
- // IMPORTANT: per-platform SHA origin differs:
90
- // - linux-x64: raw extracted-from-tarball digest (no post-build modification).
91
- // - mac-arm64: POST-CODESIGN digest. electron-builder code-signs the bundled
92
- // uv during packaging, so this hash matches what ensureUv() sees
93
- // at runtime, NOT the upstream tarball. Bumping this pin means
94
- // running the CI build, then copying the SHA from the
95
- // dmg-structural-smoke failure message — never from `shasum`
96
- // 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.
97
101
  const BUNDLED_UV_VERSION = "0.5.14";
98
102
  const BUNDLED_UV_SHA256 = {
99
103
  "linux-x64": "0e05d828b5708e8a927724124db3746396afddad6273c47283d7c562dc795bd6",
@@ -101,9 +105,12 @@ const BUNDLED_UV_SHA256 = {
101
105
  // build step. The placeholder MUST be replaced in CI before packaging
102
106
  // so runtime verification remains strict.
103
107
  "win-x64": "055d55eec85a91cfb5e9c8bc7f6463f9883866796c5bcb205fbcdfed9c088c88",
104
- // mac-arm64: POST-codesign digest. CI should populate this value when
105
- // packaging the macOS DMG and running the dmg-structural-smoke job.
106
- "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().
107
114
  };
108
115
 
109
116
  const MANAGED_UV_DIR = path.join(GAIA_HOME, "bin");
@@ -510,6 +517,27 @@ function isTransientNetworkError(output) {
510
517
  );
511
518
  }
512
519
 
520
+ /**
521
+ * Detect a TLS trust failure in `uv pip install` output. Behind a corporate
522
+ * MITM proxy, PyPI is presented with a certificate signed by a custom root CA
523
+ * that uv's bundled webpki roots don't include, so uv reports
524
+ * "invalid peer certificate: UnknownIssuer" (issue #1693). It surfaces wrapped
525
+ * in "Failed to fetch" / "error sending request", so isTransientNetworkError
526
+ * also matches it — the install loop MUST check this first and retry with
527
+ * --native-tls (the OS trust store, where IT installs the corporate CA) rather
528
+ * than burning the transient retries on the same bundled roots.
529
+ */
530
+ function isTlsCertError(output) {
531
+ if (!output) return false;
532
+ return (
533
+ /invalid peer certificate/i.test(output) ||
534
+ /unknownissuer/i.test(output) ||
535
+ /certificate verify failed/i.test(output) ||
536
+ /unable to get local issuer certificate/i.test(output) ||
537
+ /self[- ]signed certificate/i.test(output)
538
+ );
539
+ }
540
+
513
541
  function sleep(ms) {
514
542
  return new Promise((resolve) => setTimeout(resolve, ms));
515
543
  }
@@ -590,69 +618,234 @@ function checkDiskSpace() {
590
618
  }
591
619
 
592
620
  /**
593
- * Best-effort network reachability check. Performs a HEAD request to
594
- * https://astral.sh (where the uv installer lives). Resolves { ok, message? }.
621
+ * Read the OS trust store. Node 22+ exposes `tls.getCACertificates("system")`;
622
+ * older Node lacks it, so this returns `[]` there (callers fall back to
623
+ * NODE_EXTRA_CA_CERTS / the bundled Mozilla set).
624
+ *
625
+ * Why this exists: behind a corporate TLS-inspection proxy, the proxy's root
626
+ * CA is installed in the OS trust store but NOT in Node's bundled Mozilla set,
627
+ * so every HTTPS handshake fails with UNABLE_TO_GET_ISSUER_CERT_LOCALLY and the
628
+ * machine looks "offline" when it is online (issue #1572).
629
+ */
630
+ function _systemCaCertificates() {
631
+ try {
632
+ if (typeof tls.getCACertificates === "function") {
633
+ return tls.getCACertificates("system") || [];
634
+ }
635
+ } catch (err) {
636
+ log(`Could not read system CA store: ${err.message}`);
637
+ }
638
+ return [];
639
+ }
640
+
641
+ /** Read a pinned corporate root CA from NODE_EXTRA_CA_CERTS (any Node version). */
642
+ function _extraCaCertificates() {
643
+ const file = process.env.NODE_EXTRA_CA_CERTS;
644
+ if (!file) return [];
645
+ try {
646
+ return [fs.readFileSync(file, "utf8")];
647
+ } catch (err) {
648
+ log(`Could not read NODE_EXTRA_CA_CERTS (${file}): ${err.message}`);
649
+ return [];
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Build the CA trust bundle for the network probe. Passing `ca` to
655
+ * `https.request` REPLACES Node's default trust store, so when we add system /
656
+ * extra certs we must re-include the bundled Mozilla set. Returns `undefined`
657
+ * when there is nothing extra to add (probe uses Node's default trust store —
658
+ * behaviour unchanged).
659
+ */
660
+ function buildCaBundle() {
661
+ const extra = [..._systemCaCertificates(), ..._extraCaCertificates()];
662
+ if (extra.length === 0) return undefined;
663
+ return [...new Set([...tls.rootCertificates, ...extra])];
664
+ }
665
+
666
+ /** First non-empty proxy URL from the standard env vars, or null. */
667
+ function proxyForHttps() {
668
+ return (
669
+ process.env.HTTPS_PROXY ||
670
+ process.env.https_proxy ||
671
+ process.env.HTTP_PROXY ||
672
+ process.env.http_proxy ||
673
+ null
674
+ );
675
+ }
676
+
677
+ /**
678
+ * Classify a network error so the caller can tell a trust-store gap (NOT
679
+ * offline) apart from genuine connectivity loss.
680
+ * - "tls" → certificate/handshake failure (machine is online)
681
+ * - "timeout" → request timed out
682
+ * - "connectivity" → DNS/connect failure (likely offline)
683
+ */
684
+ function classifyNetworkError(err) {
685
+ const code = (err && err.code) || "";
686
+ const message = (err && err.message) || "";
687
+ if (/CERT|SELF_SIGNED|UNABLE_TO_|_SIGNATURE|ALTNAME/.test(code)) {
688
+ return "tls";
689
+ }
690
+ if (code === "ETIMEDOUT" || /timed out/i.test(message)) {
691
+ return "timeout";
692
+ }
693
+ return "connectivity";
694
+ }
695
+
696
+ /** HEAD-probe `target` (a URL) directly, trusting `ca`. */
697
+ function _probeDirect(target, ca, finish, setSock) {
698
+ const opts = {
699
+ host: target.hostname,
700
+ port: target.port || 443,
701
+ path: target.pathname || "/",
702
+ method: "HEAD",
703
+ servername: target.hostname,
704
+ headers: { "User-Agent": "gaia-backend-installer/1.0" },
705
+ };
706
+ if (ca) opts.ca = ca;
707
+ try {
708
+ const req = https.request(opts, (res) => {
709
+ res.resume();
710
+ finish({ ok: true, status: res.statusCode });
711
+ });
712
+ setSock(req);
713
+ req.on("error", (err) =>
714
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` })
715
+ );
716
+ req.end();
717
+ } catch (err) {
718
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` });
719
+ }
720
+ }
721
+
722
+ /** HEAD-probe `target` through an HTTP CONNECT proxy, trusting `ca`. */
723
+ function _probeViaProxy(target, proxy, ca, finish, setSock) {
724
+ let proxyUrl;
725
+ try {
726
+ proxyUrl = new URL(proxy);
727
+ } catch {
728
+ finish({ ok: false, kind: "connectivity", message: `${target.href}: invalid proxy URL "${proxy}"` });
729
+ return;
730
+ }
731
+ const headers = {};
732
+ if (proxyUrl.username) {
733
+ const cred = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`;
734
+ headers["Proxy-Authorization"] = "Basic " + Buffer.from(cred).toString("base64");
735
+ }
736
+ const connectReq = http.request({
737
+ host: proxyUrl.hostname,
738
+ port: proxyUrl.port || 80,
739
+ method: "CONNECT",
740
+ path: `${target.hostname}:${target.port || 443}`,
741
+ headers,
742
+ });
743
+ setSock(connectReq);
744
+ connectReq.on("connect", (res, socket) => {
745
+ if (res.statusCode !== 200) {
746
+ try { socket.destroy(); } catch { /* ignore */ }
747
+ finish({ ok: false, kind: "connectivity", message: `${target.href}: proxy CONNECT returned ${res.statusCode}` });
748
+ return;
749
+ }
750
+ setSock(socket);
751
+ const opts = {
752
+ host: target.hostname,
753
+ port: target.port || 443,
754
+ path: target.pathname || "/",
755
+ method: "HEAD",
756
+ socket,
757
+ agent: false,
758
+ servername: target.hostname,
759
+ headers: { "User-Agent": "gaia-backend-installer/1.0" },
760
+ };
761
+ if (ca) opts.ca = ca;
762
+ try {
763
+ const req = https.request(opts, (r) => {
764
+ r.resume();
765
+ finish({ ok: true, status: r.statusCode });
766
+ });
767
+ req.on("error", (err) =>
768
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` })
769
+ );
770
+ req.end();
771
+ } catch (err) {
772
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` });
773
+ }
774
+ });
775
+ connectReq.on("error", (err) =>
776
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` })
777
+ );
778
+ connectReq.end();
779
+ }
780
+
781
+ /**
782
+ * Best-effort reachability probe for a single host. A HEAD that gets any
783
+ * response (even 3xx/4xx) proves connectivity. Trusts the system / pinned CA
784
+ * store (issue #1572) and honors HTTPS_PROXY/HTTP_PROXY. Resolves
785
+ * { ok, status? } or { ok:false, kind, message }.
595
786
  */
596
787
  function _checkOneHost(url) {
597
788
  return new Promise((resolve) => {
598
789
  let settled = false;
790
+ let sock = null;
791
+ let timer = null;
599
792
  const finish = (result) => {
600
793
  if (settled) return;
601
794
  settled = true;
795
+ if (timer) clearTimeout(timer);
796
+ if (sock) {
797
+ try { sock.destroy(); } catch { /* ignore */ }
798
+ }
602
799
  resolve(result);
603
800
  };
604
-
605
- try {
606
- const req = https.request(
607
- url,
608
- {
609
- method: "HEAD",
610
- timeout: NETWORK_CHECK_TIMEOUT_MS,
611
- headers: { "User-Agent": "gaia-backend-installer/1.0" },
612
- },
613
- (res) => {
614
- // Any response (even 3xx/4xx) means we have basic connectivity.
615
- res.resume();
616
- finish({ ok: true, status: res.statusCode });
617
- }
618
- );
619
- req.on("timeout", () => {
620
- req.destroy();
621
- finish({
622
- ok: false,
623
- message: `${url}: timed out after ${NETWORK_CHECK_TIMEOUT_MS / 1000}s`,
624
- });
625
- });
626
- req.on("error", (err) => {
627
- finish({
628
- ok: false,
629
- message: `${url}: ${err.message}`,
630
- });
631
- });
632
- req.end();
633
- } catch (err) {
801
+ const setSock = (s) => {
802
+ sock = s;
803
+ };
804
+ timer = setTimeout(() => {
634
805
  finish({
635
806
  ok: false,
636
- message: `${url}: ${err.message}`,
807
+ kind: "timeout",
808
+ message: `${url}: timed out after ${NETWORK_CHECK_TIMEOUT_MS / 1000}s`,
637
809
  });
810
+ }, NETWORK_CHECK_TIMEOUT_MS);
811
+
812
+ let target;
813
+ try {
814
+ target = new URL(url);
815
+ } catch (err) {
816
+ finish({ ok: false, kind: "connectivity", message: `${url}: ${err.message}` });
817
+ return;
818
+ }
819
+ const ca = buildCaBundle();
820
+ const proxy = proxyForHttps();
821
+ if (proxy) {
822
+ _probeViaProxy(target, proxy, ca, finish, setSock);
823
+ } else {
824
+ _probeDirect(target, ca, finish, setSock);
638
825
  }
639
826
  });
640
827
  }
641
828
 
642
829
  /**
643
- * Probe each host in ``NETWORK_CHECK_HOSTS`` sequentially. Succeed as
644
- * soon as ANY host responds (even 3xx/4xx counts — it proves
645
- * connectivity). Only fail if ALL hosts are unreachable.
830
+ * Probe each host in ``NETWORK_CHECK_HOSTS`` sequentially. Succeed as soon as
831
+ * ANY host responds (even 3xx/4xx counts — it proves connectivity). On total
832
+ * failure, report the dominant failure `kind` so the caller can distinguish a
833
+ * trust-store gap ("tls" — online but Node doesn't trust the proxy CA) from a
834
+ * genuine outage ("connectivity").
646
835
  */
647
836
  async function checkNetwork() {
648
837
  const errors = [];
838
+ const kinds = new Set();
649
839
  for (const url of NETWORK_CHECK_HOSTS) {
650
840
  const result = await _checkOneHost(url);
651
- if (result.ok) return result;
841
+ if (result.ok) return { ok: true, status: result.status };
652
842
  errors.push(result.message);
843
+ kinds.add(result.kind || "connectivity");
653
844
  }
845
+ const allTls = kinds.size > 0 && [...kinds].every((k) => k === "tls");
654
846
  return {
655
847
  ok: false,
848
+ kind: allTls ? "tls" : kinds.has("connectivity") ? "connectivity" : [...kinds][0] || "connectivity",
656
849
  message: `Network check failed for all hosts: ${errors.join("; ")}`,
657
850
  };
658
851
  }
@@ -736,6 +929,27 @@ function sha256File(filePath) {
736
929
  });
737
930
  }
738
931
 
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
+
739
953
  /**
740
954
  * Resolve the bundled uv binary path inside the Electron resources dir.
741
955
  * Returns null if this isn't an Electron-packaged runtime (no
@@ -757,15 +971,22 @@ function findBundledUvResource() {
757
971
  }
758
972
 
759
973
  /**
760
- * Atomically install the bundled uv into ~/.gaia/bin/uv after verifying
761
- * 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.
976
+ *
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).
762
982
  *
763
- * Writes to `uv.tmp-<pid>-<rand>` with mode 0o700, verifies hash,
764
- * `chmod +x`, then `fs.rename()` (atomic on same filesystem).
983
+ * Writes to `uv.tmp-<pid>-<rand>` with mode 0o700, verifies, `chmod +x`,
984
+ * then `fs.rename()` (atomic on same filesystem).
765
985
  */
766
986
  async function installBundledUv(sourcePath, platformKey) {
987
+ const isDarwin = platformKey === "mac-arm64";
767
988
  const expected = BUNDLED_UV_SHA256[platformKey];
768
- if (!expected || expected.startsWith("<")) {
989
+ if (!isDarwin && (!expected || expected.startsWith("<"))) {
769
990
  // Enforce strict verification: builds MUST populate the expected SHA
770
991
  // for packaged binaries. Failing fast prevents shipping an unverified
771
992
  // uv binary which would be a supply-chain regression.
@@ -801,18 +1022,39 @@ async function installBundledUv(sourcePath, platformKey) {
801
1022
  rs.pipe(ws);
802
1023
  });
803
1024
 
804
- let actual;
805
- try {
806
- actual = await sha256File(tmpPath);
807
- } catch (err) {
808
- try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
809
- throw new InstallError(
810
- `Could not hash copied uv binary: ${err.message}`,
811
- { stage: STAGES.ENSURE_UV }
812
- );
813
- }
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
+ }
814
1057
 
815
- if (expected) {
816
1058
  if (actual !== expected) {
817
1059
  try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
818
1060
  throw new InstallError(
@@ -824,14 +1066,12 @@ async function installBundledUv(sourcePath, platformKey) {
824
1066
  }
825
1067
  );
826
1068
  }
827
- } else {
828
- log("No expected SHA registered for bundled uv; installed binary will not be verified locally.");
829
- }
830
1069
 
831
- try {
832
- if (!IS_WINDOWS) fs.chmodSync(tmpPath, 0o700);
833
- } catch (err) {
834
- 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
+ }
835
1075
  }
836
1076
 
837
1077
  try {
@@ -869,9 +1109,11 @@ function addManagedBinToPath() {
869
1109
 
870
1110
  /**
871
1111
  * Ensure `uv` is available. Preference order (per issue #782 / T3):
872
- * 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.
873
1114
  * 2. Bundled binary in process.resourcesPath/vendor/uv/<platform>/uv:
874
- * copy atomically to ~/.gaia/bin/uv with SHA256 verification.
1115
+ * copy atomically to ~/.gaia/bin/uv with the same platform-appropriate
1116
+ * verification.
875
1117
  * 3. DEV-ONLY fallback (app.isPackaged === false OR no resourcesPath):
876
1118
  * the original `curl | sh` from astral.sh. Not a shipped-user path.
877
1119
  * 4. System `uv` on PATH (last resort — unverified version).
@@ -883,10 +1125,20 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
883
1125
  report(STAGES.ENSURE_UV, 0, "Checking uv (Python package manager)");
884
1126
 
885
1127
  const platformKey = bundledUvPlatformKey();
1128
+ const isDarwin = platformKey === "mac-arm64";
886
1129
  const expectedSha = platformKey ? BUNDLED_UV_SHA256[platformKey] : null;
887
1130
 
888
- // Fast path: warm install already on disk with correct hash.
889
- 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)) {
890
1142
  try {
891
1143
  const actual = await sha256File(MANAGED_UV_BIN);
892
1144
  if (actual === expectedSha) {
@@ -909,22 +1161,36 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
909
1161
  report(STAGES.ENSURE_UV, 30, "Installing bundled uv");
910
1162
  log(`Using bundled uv from ${bundled}`);
911
1163
 
912
- // Verify the source resource matches the manifest before copying —
913
- // catches AppImage corruption before we touch the user's home.
914
- // Enforce that the packaged build provides an expected SHA for the
915
- // bundled resource. CI replaces the placeholder with the extracted
916
- // binary's SHA during the build; missing/placeholder values are a
917
- // build-time error and are rejected at runtime here.
918
- const srcHash = await sha256File(bundled);
919
- if (srcHash !== expectedSha) {
920
- throw new InstallError(
921
- `Bundled uv resource SHA256 mismatch (expected ${expectedSha}, got ${srcHash}).`,
922
- {
923
- stage: STAGES.ENSURE_UV,
924
- suggestion:
925
- "The installer appears to be corrupt. Re-download GAIA from https://amd-gaia.ai and try again.",
926
- }
927
- );
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
+ }
928
1194
  }
929
1195
  await installBundledUv(bundled, platformKey);
930
1196
  addManagedBinToPath();
@@ -1198,10 +1464,38 @@ async function installBackend(opts = {}) {
1198
1464
  // would otherwise fail the whole bootstrap (and block a release). File-lock
1199
1465
  // failures (Windows os-error-32) are NOT retried — they need user action.
1200
1466
  let installResult;
1201
- for (let attempt = 1; attempt <= INSTALL_MAX_ATTEMPTS; attempt++) {
1202
- installResult = await runCommand("uv", pipArgs, { stageLabel: "pip" });
1467
+ let useNativeTls = false;
1468
+ let nativeTlsAttempted = false;
1469
+ // The TLS branch flips useNativeTls and `continue`s, consuming an iteration.
1470
+ // If the signature only surfaces on the final attempt, extend the loop once
1471
+ // so the promised --native-tls retry actually runs (issue #1693 review).
1472
+ for (
1473
+ let attempt = 1;
1474
+ attempt <= INSTALL_MAX_ATTEMPTS || (useNativeTls && !nativeTlsAttempted);
1475
+ attempt++
1476
+ ) {
1477
+ const attemptArgs = useNativeTls ? [...pipArgs, "--native-tls"] : pipArgs;
1478
+ if (useNativeTls) nativeTlsAttempted = true;
1479
+ installResult = await runCommand("uv", attemptArgs, { stageLabel: "pip" });
1203
1480
  if (installResult.code === 0) break;
1204
1481
  const attemptOutput = `${installResult.stdout || ""}\n${installResult.stderr || ""}`;
1482
+ // A corporate proxy's custom root CA isn't in uv's bundled webpki roots, so
1483
+ // the fetch fails with UnknownIssuer. This LOOKS transient ("Failed to
1484
+ // fetch") but retrying the bundled roots can't fix it — retry once with
1485
+ // --native-tls so uv trusts the OS certificate store instead (issue #1693).
1486
+ if (!useNativeTls && isTlsCertError(attemptOutput)) {
1487
+ useNativeTls = true;
1488
+ log(
1489
+ "TLS trust failure (corporate proxy CA not in uv's bundled roots) — " +
1490
+ "retrying install with --native-tls to use the OS certificate store"
1491
+ );
1492
+ report(
1493
+ STAGES.INSTALL_PACKAGE,
1494
+ 0,
1495
+ "Certificate trust issue — retrying with the system certificate store"
1496
+ );
1497
+ continue;
1498
+ }
1205
1499
  const canRetry =
1206
1500
  attempt < INSTALL_MAX_ATTEMPTS &&
1207
1501
  isTransientNetworkError(attemptOutput) &&
@@ -1225,11 +1519,23 @@ async function installBackend(opts = {}) {
1225
1519
  // main.cjs should have killed it first; if the lock persists, point the
1226
1520
  // user at the live process rather than the generic manual-install hint.
1227
1521
  const output = `${installResult.stdout || ""}\n${installResult.stderr || ""}`;
1228
- const suggestion = isFileLockedError(output)
1229
- ? "A running GAIA process is locking the install. Close GAIA completely (including any leftover gaia.exe in Task Manager), then relaunch."
1230
- : `Try installing manually:\n uv pip install ${pipPackage} --python ${
1231
- IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
1232
- }\nThen restart GAIA. See https://amd-gaia.ai/quickstart#cli-install`;
1522
+ let suggestion;
1523
+ if (isFileLockedError(output)) {
1524
+ suggestion =
1525
+ "A running GAIA process is locking the install. Close GAIA completely (including any leftover gaia.exe in Task Manager), then relaunch.";
1526
+ } else if (isTlsCertError(output)) {
1527
+ suggestion =
1528
+ "PyPI's certificate isn't trusted on this network — usually a corporate proxy presenting its own root CA. " +
1529
+ (nativeTlsAttempted
1530
+ ? "GAIA retried with the OS certificate store and it still failed. "
1531
+ : "") +
1532
+ "Ask IT to install the proxy's root CA in your system's trusted certificate store, then relaunch GAIA. " +
1533
+ "See https://amd-gaia.ai/quickstart#cli-install";
1534
+ } else {
1535
+ suggestion = `Try installing manually:\n uv pip install ${pipPackage} --python ${
1536
+ IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
1537
+ }\nThen restart GAIA. See https://amd-gaia.ai/quickstart#cli-install`;
1538
+ }
1233
1539
  throw new InstallError(
1234
1540
  `Failed to install ${pipPackage} (pip exit ${installResult.code}).`,
1235
1541
  {
@@ -1426,18 +1732,25 @@ async function ensureBackend(opts = {}) {
1426
1732
  throw err;
1427
1733
  }
1428
1734
 
1429
- // Network check failure: fatal, surface as InstallError.
1735
+ // Network check is ADVISORY, never fatal. A failed HEAD probe is most often
1736
+ // a corporate TLS-inspection proxy whose root CA isn't in Node's bundled
1737
+ // trust store (#1572) — the machine is online and `uv`/`pip`, which honor
1738
+ // HTTPS_PROXY and the system trust store, install fine. If the host really
1739
+ // is unreachable, the install step below fails loudly with an accurate
1740
+ // uv/pip error, which is more actionable than a generic "offline".
1430
1741
  if (!preChecks.network.ok) {
1431
- const err = new InstallError(
1432
- `You appear to be offline. ${preChecks.network.message || "Could not reach any network host."}`,
1433
- {
1434
- stage: STAGES.PRE_CHECKS,
1435
- suggestion:
1436
- "Connect to the internet and try again. If you are behind a corporate proxy, configure HTTPS_PROXY and re-launch GAIA.",
1437
- }
1438
- );
1439
- setState(STATES.FAILED, { stage: STAGES.PRE_CHECKS, message: err.message });
1440
- throw err;
1742
+ if (preChecks.network.kind === "tls") {
1743
+ log(
1744
+ `Network pre-check hit a TLS/certificate error (not offline): ${preChecks.network.message}. ` +
1745
+ "Typically a corporate proxy root CA missing from Node's trust store — " +
1746
+ "set NODE_EXTRA_CA_CERTS to it (or relaunch with --use-system-ca) if the install fails. Proceeding."
1747
+ );
1748
+ } else {
1749
+ log(
1750
+ `Network pre-check could not reach any host: ${preChecks.network.message}. ` +
1751
+ "Proceeding anyway; the install step will surface an accurate error if the network is truly unreachable."
1752
+ );
1753
+ }
1441
1754
  }
1442
1755
 
1443
1756
  // Fast-path: already installed at the expected version.
@@ -1521,6 +1834,11 @@ module.exports = {
1521
1834
  checkNetwork,
1522
1835
  isFileLockedError,
1523
1836
  isTransientNetworkError,
1837
+ isTlsCertError,
1838
+ buildCaBundle,
1839
+ proxyForHttps,
1840
+ classifyNetworkError,
1841
+ _checkOneHost,
1524
1842
 
1525
1843
  // State machine
1526
1844
  getState,