@amd-gaia/agent-ui 0.21.1 → 0.21.2

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
 
@@ -510,6 +512,27 @@ function isTransientNetworkError(output) {
510
512
  );
511
513
  }
512
514
 
515
+ /**
516
+ * Detect a TLS trust failure in `uv pip install` output. Behind a corporate
517
+ * MITM proxy, PyPI is presented with a certificate signed by a custom root CA
518
+ * that uv's bundled webpki roots don't include, so uv reports
519
+ * "invalid peer certificate: UnknownIssuer" (issue #1693). It surfaces wrapped
520
+ * in "Failed to fetch" / "error sending request", so isTransientNetworkError
521
+ * also matches it — the install loop MUST check this first and retry with
522
+ * --native-tls (the OS trust store, where IT installs the corporate CA) rather
523
+ * than burning the transient retries on the same bundled roots.
524
+ */
525
+ function isTlsCertError(output) {
526
+ if (!output) return false;
527
+ return (
528
+ /invalid peer certificate/i.test(output) ||
529
+ /unknownissuer/i.test(output) ||
530
+ /certificate verify failed/i.test(output) ||
531
+ /unable to get local issuer certificate/i.test(output) ||
532
+ /self[- ]signed certificate/i.test(output)
533
+ );
534
+ }
535
+
513
536
  function sleep(ms) {
514
537
  return new Promise((resolve) => setTimeout(resolve, ms));
515
538
  }
@@ -590,69 +613,234 @@ function checkDiskSpace() {
590
613
  }
591
614
 
592
615
  /**
593
- * Best-effort network reachability check. Performs a HEAD request to
594
- * https://astral.sh (where the uv installer lives). Resolves { ok, message? }.
616
+ * Read the OS trust store. Node 22+ exposes `tls.getCACertificates("system")`;
617
+ * older Node lacks it, so this returns `[]` there (callers fall back to
618
+ * NODE_EXTRA_CA_CERTS / the bundled Mozilla set).
619
+ *
620
+ * Why this exists: behind a corporate TLS-inspection proxy, the proxy's root
621
+ * CA is installed in the OS trust store but NOT in Node's bundled Mozilla set,
622
+ * so every HTTPS handshake fails with UNABLE_TO_GET_ISSUER_CERT_LOCALLY and the
623
+ * machine looks "offline" when it is online (issue #1572).
624
+ */
625
+ function _systemCaCertificates() {
626
+ try {
627
+ if (typeof tls.getCACertificates === "function") {
628
+ return tls.getCACertificates("system") || [];
629
+ }
630
+ } catch (err) {
631
+ log(`Could not read system CA store: ${err.message}`);
632
+ }
633
+ return [];
634
+ }
635
+
636
+ /** Read a pinned corporate root CA from NODE_EXTRA_CA_CERTS (any Node version). */
637
+ function _extraCaCertificates() {
638
+ const file = process.env.NODE_EXTRA_CA_CERTS;
639
+ if (!file) return [];
640
+ try {
641
+ return [fs.readFileSync(file, "utf8")];
642
+ } catch (err) {
643
+ log(`Could not read NODE_EXTRA_CA_CERTS (${file}): ${err.message}`);
644
+ return [];
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Build the CA trust bundle for the network probe. Passing `ca` to
650
+ * `https.request` REPLACES Node's default trust store, so when we add system /
651
+ * extra certs we must re-include the bundled Mozilla set. Returns `undefined`
652
+ * when there is nothing extra to add (probe uses Node's default trust store —
653
+ * behaviour unchanged).
654
+ */
655
+ function buildCaBundle() {
656
+ const extra = [..._systemCaCertificates(), ..._extraCaCertificates()];
657
+ if (extra.length === 0) return undefined;
658
+ return [...new Set([...tls.rootCertificates, ...extra])];
659
+ }
660
+
661
+ /** First non-empty proxy URL from the standard env vars, or null. */
662
+ function proxyForHttps() {
663
+ return (
664
+ process.env.HTTPS_PROXY ||
665
+ process.env.https_proxy ||
666
+ process.env.HTTP_PROXY ||
667
+ process.env.http_proxy ||
668
+ null
669
+ );
670
+ }
671
+
672
+ /**
673
+ * Classify a network error so the caller can tell a trust-store gap (NOT
674
+ * offline) apart from genuine connectivity loss.
675
+ * - "tls" → certificate/handshake failure (machine is online)
676
+ * - "timeout" → request timed out
677
+ * - "connectivity" → DNS/connect failure (likely offline)
678
+ */
679
+ function classifyNetworkError(err) {
680
+ const code = (err && err.code) || "";
681
+ const message = (err && err.message) || "";
682
+ if (/CERT|SELF_SIGNED|UNABLE_TO_|_SIGNATURE|ALTNAME/.test(code)) {
683
+ return "tls";
684
+ }
685
+ if (code === "ETIMEDOUT" || /timed out/i.test(message)) {
686
+ return "timeout";
687
+ }
688
+ return "connectivity";
689
+ }
690
+
691
+ /** HEAD-probe `target` (a URL) directly, trusting `ca`. */
692
+ function _probeDirect(target, ca, finish, setSock) {
693
+ const opts = {
694
+ host: target.hostname,
695
+ port: target.port || 443,
696
+ path: target.pathname || "/",
697
+ method: "HEAD",
698
+ servername: target.hostname,
699
+ headers: { "User-Agent": "gaia-backend-installer/1.0" },
700
+ };
701
+ if (ca) opts.ca = ca;
702
+ try {
703
+ const req = https.request(opts, (res) => {
704
+ res.resume();
705
+ finish({ ok: true, status: res.statusCode });
706
+ });
707
+ setSock(req);
708
+ req.on("error", (err) =>
709
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` })
710
+ );
711
+ req.end();
712
+ } catch (err) {
713
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` });
714
+ }
715
+ }
716
+
717
+ /** HEAD-probe `target` through an HTTP CONNECT proxy, trusting `ca`. */
718
+ function _probeViaProxy(target, proxy, ca, finish, setSock) {
719
+ let proxyUrl;
720
+ try {
721
+ proxyUrl = new URL(proxy);
722
+ } catch {
723
+ finish({ ok: false, kind: "connectivity", message: `${target.href}: invalid proxy URL "${proxy}"` });
724
+ return;
725
+ }
726
+ const headers = {};
727
+ if (proxyUrl.username) {
728
+ const cred = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`;
729
+ headers["Proxy-Authorization"] = "Basic " + Buffer.from(cred).toString("base64");
730
+ }
731
+ const connectReq = http.request({
732
+ host: proxyUrl.hostname,
733
+ port: proxyUrl.port || 80,
734
+ method: "CONNECT",
735
+ path: `${target.hostname}:${target.port || 443}`,
736
+ headers,
737
+ });
738
+ setSock(connectReq);
739
+ connectReq.on("connect", (res, socket) => {
740
+ if (res.statusCode !== 200) {
741
+ try { socket.destroy(); } catch { /* ignore */ }
742
+ finish({ ok: false, kind: "connectivity", message: `${target.href}: proxy CONNECT returned ${res.statusCode}` });
743
+ return;
744
+ }
745
+ setSock(socket);
746
+ const opts = {
747
+ host: target.hostname,
748
+ port: target.port || 443,
749
+ path: target.pathname || "/",
750
+ method: "HEAD",
751
+ socket,
752
+ agent: false,
753
+ servername: target.hostname,
754
+ headers: { "User-Agent": "gaia-backend-installer/1.0" },
755
+ };
756
+ if (ca) opts.ca = ca;
757
+ try {
758
+ const req = https.request(opts, (r) => {
759
+ r.resume();
760
+ finish({ ok: true, status: r.statusCode });
761
+ });
762
+ req.on("error", (err) =>
763
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` })
764
+ );
765
+ req.end();
766
+ } catch (err) {
767
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` });
768
+ }
769
+ });
770
+ connectReq.on("error", (err) =>
771
+ finish({ ok: false, kind: classifyNetworkError(err), message: `${target.href}: ${err.message}` })
772
+ );
773
+ connectReq.end();
774
+ }
775
+
776
+ /**
777
+ * Best-effort reachability probe for a single host. A HEAD that gets any
778
+ * response (even 3xx/4xx) proves connectivity. Trusts the system / pinned CA
779
+ * store (issue #1572) and honors HTTPS_PROXY/HTTP_PROXY. Resolves
780
+ * { ok, status? } or { ok:false, kind, message }.
595
781
  */
596
782
  function _checkOneHost(url) {
597
783
  return new Promise((resolve) => {
598
784
  let settled = false;
785
+ let sock = null;
786
+ let timer = null;
599
787
  const finish = (result) => {
600
788
  if (settled) return;
601
789
  settled = true;
790
+ if (timer) clearTimeout(timer);
791
+ if (sock) {
792
+ try { sock.destroy(); } catch { /* ignore */ }
793
+ }
602
794
  resolve(result);
603
795
  };
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) {
796
+ const setSock = (s) => {
797
+ sock = s;
798
+ };
799
+ timer = setTimeout(() => {
634
800
  finish({
635
801
  ok: false,
636
- message: `${url}: ${err.message}`,
802
+ kind: "timeout",
803
+ message: `${url}: timed out after ${NETWORK_CHECK_TIMEOUT_MS / 1000}s`,
637
804
  });
805
+ }, NETWORK_CHECK_TIMEOUT_MS);
806
+
807
+ let target;
808
+ try {
809
+ target = new URL(url);
810
+ } catch (err) {
811
+ finish({ ok: false, kind: "connectivity", message: `${url}: ${err.message}` });
812
+ return;
813
+ }
814
+ const ca = buildCaBundle();
815
+ const proxy = proxyForHttps();
816
+ if (proxy) {
817
+ _probeViaProxy(target, proxy, ca, finish, setSock);
818
+ } else {
819
+ _probeDirect(target, ca, finish, setSock);
638
820
  }
639
821
  });
640
822
  }
641
823
 
642
824
  /**
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.
825
+ * Probe each host in ``NETWORK_CHECK_HOSTS`` sequentially. Succeed as soon as
826
+ * ANY host responds (even 3xx/4xx counts — it proves connectivity). On total
827
+ * failure, report the dominant failure `kind` so the caller can distinguish a
828
+ * trust-store gap ("tls" — online but Node doesn't trust the proxy CA) from a
829
+ * genuine outage ("connectivity").
646
830
  */
647
831
  async function checkNetwork() {
648
832
  const errors = [];
833
+ const kinds = new Set();
649
834
  for (const url of NETWORK_CHECK_HOSTS) {
650
835
  const result = await _checkOneHost(url);
651
- if (result.ok) return result;
836
+ if (result.ok) return { ok: true, status: result.status };
652
837
  errors.push(result.message);
838
+ kinds.add(result.kind || "connectivity");
653
839
  }
840
+ const allTls = kinds.size > 0 && [...kinds].every((k) => k === "tls");
654
841
  return {
655
842
  ok: false,
843
+ kind: allTls ? "tls" : kinds.has("connectivity") ? "connectivity" : [...kinds][0] || "connectivity",
656
844
  message: `Network check failed for all hosts: ${errors.join("; ")}`,
657
845
  };
658
846
  }
@@ -1198,10 +1386,38 @@ async function installBackend(opts = {}) {
1198
1386
  // would otherwise fail the whole bootstrap (and block a release). File-lock
1199
1387
  // failures (Windows os-error-32) are NOT retried — they need user action.
1200
1388
  let installResult;
1201
- for (let attempt = 1; attempt <= INSTALL_MAX_ATTEMPTS; attempt++) {
1202
- installResult = await runCommand("uv", pipArgs, { stageLabel: "pip" });
1389
+ let useNativeTls = false;
1390
+ let nativeTlsAttempted = false;
1391
+ // The TLS branch flips useNativeTls and `continue`s, consuming an iteration.
1392
+ // If the signature only surfaces on the final attempt, extend the loop once
1393
+ // so the promised --native-tls retry actually runs (issue #1693 review).
1394
+ for (
1395
+ let attempt = 1;
1396
+ attempt <= INSTALL_MAX_ATTEMPTS || (useNativeTls && !nativeTlsAttempted);
1397
+ attempt++
1398
+ ) {
1399
+ const attemptArgs = useNativeTls ? [...pipArgs, "--native-tls"] : pipArgs;
1400
+ if (useNativeTls) nativeTlsAttempted = true;
1401
+ installResult = await runCommand("uv", attemptArgs, { stageLabel: "pip" });
1203
1402
  if (installResult.code === 0) break;
1204
1403
  const attemptOutput = `${installResult.stdout || ""}\n${installResult.stderr || ""}`;
1404
+ // A corporate proxy's custom root CA isn't in uv's bundled webpki roots, so
1405
+ // the fetch fails with UnknownIssuer. This LOOKS transient ("Failed to
1406
+ // fetch") but retrying the bundled roots can't fix it — retry once with
1407
+ // --native-tls so uv trusts the OS certificate store instead (issue #1693).
1408
+ if (!useNativeTls && isTlsCertError(attemptOutput)) {
1409
+ useNativeTls = true;
1410
+ log(
1411
+ "TLS trust failure (corporate proxy CA not in uv's bundled roots) — " +
1412
+ "retrying install with --native-tls to use the OS certificate store"
1413
+ );
1414
+ report(
1415
+ STAGES.INSTALL_PACKAGE,
1416
+ 0,
1417
+ "Certificate trust issue — retrying with the system certificate store"
1418
+ );
1419
+ continue;
1420
+ }
1205
1421
  const canRetry =
1206
1422
  attempt < INSTALL_MAX_ATTEMPTS &&
1207
1423
  isTransientNetworkError(attemptOutput) &&
@@ -1225,11 +1441,23 @@ async function installBackend(opts = {}) {
1225
1441
  // main.cjs should have killed it first; if the lock persists, point the
1226
1442
  // user at the live process rather than the generic manual-install hint.
1227
1443
  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`;
1444
+ let suggestion;
1445
+ if (isFileLockedError(output)) {
1446
+ suggestion =
1447
+ "A running GAIA process is locking the install. Close GAIA completely (including any leftover gaia.exe in Task Manager), then relaunch.";
1448
+ } else if (isTlsCertError(output)) {
1449
+ suggestion =
1450
+ "PyPI's certificate isn't trusted on this network — usually a corporate proxy presenting its own root CA. " +
1451
+ (nativeTlsAttempted
1452
+ ? "GAIA retried with the OS certificate store and it still failed. "
1453
+ : "") +
1454
+ "Ask IT to install the proxy's root CA in your system's trusted certificate store, then relaunch GAIA. " +
1455
+ "See https://amd-gaia.ai/quickstart#cli-install";
1456
+ } else {
1457
+ suggestion = `Try installing manually:\n uv pip install ${pipPackage} --python ${
1458
+ IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
1459
+ }\nThen restart GAIA. See https://amd-gaia.ai/quickstart#cli-install`;
1460
+ }
1233
1461
  throw new InstallError(
1234
1462
  `Failed to install ${pipPackage} (pip exit ${installResult.code}).`,
1235
1463
  {
@@ -1426,18 +1654,25 @@ async function ensureBackend(opts = {}) {
1426
1654
  throw err;
1427
1655
  }
1428
1656
 
1429
- // Network check failure: fatal, surface as InstallError.
1657
+ // Network check is ADVISORY, never fatal. A failed HEAD probe is most often
1658
+ // a corporate TLS-inspection proxy whose root CA isn't in Node's bundled
1659
+ // trust store (#1572) — the machine is online and `uv`/`pip`, which honor
1660
+ // HTTPS_PROXY and the system trust store, install fine. If the host really
1661
+ // is unreachable, the install step below fails loudly with an accurate
1662
+ // uv/pip error, which is more actionable than a generic "offline".
1430
1663
  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;
1664
+ if (preChecks.network.kind === "tls") {
1665
+ log(
1666
+ `Network pre-check hit a TLS/certificate error (not offline): ${preChecks.network.message}. ` +
1667
+ "Typically a corporate proxy root CA missing from Node's trust store — " +
1668
+ "set NODE_EXTRA_CA_CERTS to it (or relaunch with --use-system-ca) if the install fails. Proceeding."
1669
+ );
1670
+ } else {
1671
+ log(
1672
+ `Network pre-check could not reach any host: ${preChecks.network.message}. ` +
1673
+ "Proceeding anyway; the install step will surface an accurate error if the network is truly unreachable."
1674
+ );
1675
+ }
1441
1676
  }
1442
1677
 
1443
1678
  // Fast-path: already installed at the expected version.
@@ -1521,6 +1756,11 @@ module.exports = {
1521
1756
  checkNetwork,
1522
1757
  isFileLockedError,
1523
1758
  isTransientNetworkError,
1759
+ isTlsCertError,
1760
+ buildCaBundle,
1761
+ proxyForHttps,
1762
+ classifyNetworkError,
1763
+ _checkOneHost,
1524
1764
 
1525
1765
  // State machine
1526
1766
  getState,