@amd-gaia/agent-ui 0.17.3 → 0.17.4

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.
@@ -31,6 +31,7 @@
31
31
  "use strict";
32
32
 
33
33
  const { spawn, spawnSync, execSync } = require("child_process");
34
+ const crypto = require("crypto");
34
35
  const fs = require("fs");
35
36
  const path = require("path");
36
37
  const os = require("os");
@@ -61,12 +62,31 @@ const NETWORK_CHECK_HOSTS = Object.freeze([
61
62
  ]);
62
63
  const NETWORK_CHECK_TIMEOUT_MS = 5000;
63
64
 
64
- // TODO: Pin uv to a specific version and verify SHA256 of the downloaded
65
- // binary. Currently ensureUv() uses the unversioned astral.sh install
66
- // script which always fetches the latest release. A follow-up should
67
- // download a specific release tarball from GitHub and verify its SHA256
68
- // against known-good hashes. See:
69
- // https://github.com/astral-sh/uv/releases
65
+ // ── Bundled `uv` binary ──────────────────────────────────────────────────────
66
+ //
67
+ // Issue #782 / T3: the AppImage now ships a pinned `uv` under
68
+ // `extraResources` (see electron-builder.yml). At runtime we copy it into
69
+ // `~/.gaia/bin/uv` with atomic-rename + SHA256 verification. The previous
70
+ // `curl | sh` path is retained only as an unpackaged-dev fallback so
71
+ // contributors running from source keep working.
72
+ //
73
+ // When bumping uv, update BOTH:
74
+ // - .github/workflows/build-installers.yml (tarball .tar.gz SHA256 — archive)
75
+ // - BUNDLED_UV_SHA256 below (extracted ELF binary SHA256)
76
+ // These are two different digests: the workflow verifies the downloaded
77
+ // archive against upstream's published .sha256, then extracts the `uv` binary
78
+ // which is what `ensureUv()` hashes at runtime.
79
+ //
80
+ // Currently pinned: uv v0.5.14 linux-x64.
81
+ const BUNDLED_UV_VERSION = "0.5.14";
82
+ const BUNDLED_UV_SHA256 = {
83
+ "linux-x64": "0e05d828b5708e8a927724124db3746396afddad6273c47283d7c562dc795bd6",
84
+ };
85
+
86
+ const MANAGED_UV_DIR = path.join(GAIA_HOME, "bin");
87
+ const MANAGED_UV_BIN = IS_WINDOWS
88
+ ? path.join(MANAGED_UV_DIR, "uv.exe")
89
+ : path.join(MANAGED_UV_DIR, "uv");
70
90
 
71
91
  const STATES = Object.freeze({
72
92
  IDLE: "idle",
@@ -624,90 +644,321 @@ class InstallError extends Error {
624
644
  }
625
645
 
626
646
  /**
627
- * Ensure `uv` is available. Installs it from astral.sh if missing.
628
- * Throws an InstallError if installation fails.
647
+ * Which `extraResources` subdirectory holds the bundled uv for this host.
648
+ * Returns null for platforms we don't yet ship a binary for (falls through
649
+ * to the dev fallback).
629
650
  */
630
- async function ensureUv({ onProgress } = {}) {
631
- const report = makeProgressReporter(onProgress);
651
+ function bundledUvPlatformKey() {
652
+ if (process.platform === "linux" && process.arch === "x64") return "linux-x64";
653
+ if (process.platform === "win32" && process.arch === "x64") return "win-x64";
654
+ if (process.platform === "darwin" && process.arch === "arm64") return "mac-arm64";
655
+ return null;
656
+ }
632
657
 
633
- if (commandExists("uv")) {
634
- log("uv already installed");
635
- report(STAGES.ENSURE_UV, 100, "uv is already installed");
636
- return;
637
- }
658
+ /**
659
+ * Stream-SHA256 a file. Returns lowercase hex.
660
+ */
661
+ function sha256File(filePath) {
662
+ return new Promise((resolve, reject) => {
663
+ const hash = crypto.createHash("sha256");
664
+ const stream = fs.createReadStream(filePath);
665
+ stream.on("error", reject);
666
+ stream.on("data", (chunk) => hash.update(chunk));
667
+ stream.on("end", () => resolve(hash.digest("hex")));
668
+ });
669
+ }
670
+
671
+ /**
672
+ * Resolve the bundled uv binary path inside the Electron resources dir.
673
+ * Returns null if this isn't an Electron-packaged runtime (no
674
+ * `process.resourcesPath`) or if the host platform isn't bundled.
675
+ */
676
+ function findBundledUvResource() {
677
+ const key = bundledUvPlatformKey();
678
+ if (!key) return null;
679
+ const resourcesPath = process.resourcesPath;
680
+ if (!resourcesPath) return null;
681
+ const candidate = path.join(
682
+ resourcesPath,
683
+ "vendor",
684
+ "uv",
685
+ key,
686
+ IS_WINDOWS ? "uv.exe" : "uv"
687
+ );
688
+ return fs.existsSync(candidate) ? candidate : null;
689
+ }
638
690
 
639
- report(STAGES.ENSURE_UV, 0, "Installing uv (Python package manager)");
640
- log("Installing uv...");
691
+ /**
692
+ * Atomically install the bundled uv into ~/.gaia/bin/uv after verifying
693
+ * its SHA256 against BUNDLED_UV_SHA256. Returns the installed path.
694
+ *
695
+ * Writes to `uv.tmp-<pid>-<rand>` with mode 0o700, verifies hash,
696
+ * `chmod +x`, then `fs.rename()` (atomic on same filesystem).
697
+ */
698
+ async function installBundledUv(sourcePath, platformKey) {
699
+ const expected = BUNDLED_UV_SHA256[platformKey];
700
+ if (!expected) {
701
+ throw new InstallError(
702
+ `No bundled uv checksum registered for platform ${platformKey}.`,
703
+ { stage: STAGES.ENSURE_UV }
704
+ );
705
+ }
641
706
 
642
- let result;
643
- if (IS_WINDOWS) {
644
- result = await runCommand(
645
- "powershell",
646
- ["-ExecutionPolicy", "Bypass", "-Command", "irm https://astral.sh/uv/install.ps1 | iex"],
647
- { stageLabel: "uv-install" }
707
+ ensureGaiaHome();
708
+ try {
709
+ fs.mkdirSync(MANAGED_UV_DIR, { recursive: true });
710
+ } catch (err) {
711
+ throw new InstallError(
712
+ `Could not create ${MANAGED_UV_DIR}: ${err.message}`,
713
+ { stage: STAGES.ENSURE_UV }
648
714
  );
649
- } else {
650
- result = await runCommand(
651
- "bash",
652
- ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
653
- { stageLabel: "uv-install" }
715
+ }
716
+
717
+ const rand = crypto.randomBytes(6).toString("hex");
718
+ const tmpPath = path.join(
719
+ MANAGED_UV_DIR,
720
+ `uv.tmp-${process.pid}-${rand}${IS_WINDOWS ? ".exe" : ""}`
721
+ );
722
+
723
+ // Copy source → tmp with restrictive mode.
724
+ await new Promise((resolve, reject) => {
725
+ const rs = fs.createReadStream(sourcePath);
726
+ const ws = fs.createWriteStream(tmpPath, { mode: 0o700 });
727
+ rs.on("error", reject);
728
+ ws.on("error", reject);
729
+ ws.on("finish", resolve);
730
+ rs.pipe(ws);
731
+ });
732
+
733
+ let actual;
734
+ try {
735
+ actual = await sha256File(tmpPath);
736
+ } catch (err) {
737
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
738
+ throw new InstallError(
739
+ `Could not hash copied uv binary: ${err.message}`,
740
+ { stage: STAGES.ENSURE_UV }
654
741
  );
655
742
  }
656
743
 
657
- if (result.code !== 0) {
744
+ if (actual !== expected) {
745
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
658
746
  throw new InstallError(
659
- `Could not install uv automatically (exit code ${result.code}).`,
747
+ `Bundled uv SHA256 mismatch (expected ${expected}, got ${actual}).`,
660
748
  {
661
749
  stage: STAGES.ENSURE_UV,
662
- code: result.code,
663
- suggestion: IS_WINDOWS
664
- ? 'Install uv manually: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
665
- : "Install uv manually: curl -LsSf https://astral.sh/uv/install.sh | sh",
750
+ suggestion:
751
+ "The AppImage/installer may be corrupt. Re-download from https://amd-gaia.ai and try again.",
666
752
  }
667
753
  );
668
754
  }
669
755
 
670
- // On some shells, PATH isn't refreshed for the current process. Re-check
671
- // after adding the default uv install dirs. uv has shipped under both
672
- // ~/.cargo/bin and ~/.local/bin at different times — most notably the
673
- // Windows installer migrated from .cargo/bin to %USERPROFILE%\.local\bin
674
- // in early 2025. Try BOTH paths on every platform to be robust against
675
- // installer version drift.
676
- if (!commandExists("uv")) {
677
- const candidates = [
678
- path.join(os.homedir(), ".local", "bin"),
679
- path.join(os.homedir(), ".cargo", "bin"),
680
- ];
681
- for (const uvDir of candidates) {
682
- if (process.env.PATH && !process.env.PATH.includes(uvDir)) {
683
- process.env.PATH = `${uvDir}${path.delimiter}${process.env.PATH}`;
684
- log(`Added ${uvDir} to PATH for this process`);
756
+ try {
757
+ if (!IS_WINDOWS) fs.chmodSync(tmpPath, 0o700);
758
+ } catch (err) {
759
+ log(`Warning: chmod on tmp uv failed: ${err.message}`);
760
+ }
761
+
762
+ try {
763
+ // rename() is atomic on the same filesystem on POSIX; on Windows
764
+ // it requires the target not to exist, so unlink first.
765
+ if (IS_WINDOWS && fs.existsSync(MANAGED_UV_BIN)) {
766
+ try { fs.unlinkSync(MANAGED_UV_BIN); } catch { /* ignore */ }
767
+ }
768
+ fs.renameSync(tmpPath, MANAGED_UV_BIN);
769
+ } catch (err) {
770
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
771
+ throw new InstallError(
772
+ `Could not install uv to ${MANAGED_UV_BIN}: ${err.message}`,
773
+ { stage: STAGES.ENSURE_UV }
774
+ );
775
+ }
776
+
777
+ log(`Installed bundled uv v${BUNDLED_UV_VERSION} → ${MANAGED_UV_BIN}`);
778
+ return MANAGED_UV_BIN;
779
+ }
780
+
781
+ /**
782
+ * Prepend ~/.gaia/bin to this process's PATH so child spawns see our
783
+ * managed uv before any system-wide install.
784
+ */
785
+ function addManagedBinToPath() {
786
+ if (
787
+ process.env.PATH &&
788
+ !process.env.PATH.split(path.delimiter).includes(MANAGED_UV_DIR)
789
+ ) {
790
+ process.env.PATH = `${MANAGED_UV_DIR}${path.delimiter}${process.env.PATH}`;
791
+ log(`Prepended ${MANAGED_UV_DIR} to PATH for this process`);
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Ensure `uv` is available. Preference order (per issue #782 / T3):
797
+ * 1. Managed copy at ~/.gaia/bin/uv with matching SHA256 (warm-install fast path).
798
+ * 2. Bundled binary in process.resourcesPath/vendor/uv/<platform>/uv:
799
+ * copy atomically to ~/.gaia/bin/uv with SHA256 verification.
800
+ * 3. DEV-ONLY fallback (app.isPackaged === false OR no resourcesPath):
801
+ * the original `curl | sh` from astral.sh. Not a shipped-user path.
802
+ * 4. System `uv` on PATH (last resort — unverified version).
803
+ *
804
+ * Throws InstallError on failure.
805
+ */
806
+ async function ensureUv({ onProgress, isPackaged } = {}) {
807
+ const report = makeProgressReporter(onProgress);
808
+ report(STAGES.ENSURE_UV, 0, "Checking uv (Python package manager)");
809
+
810
+ const platformKey = bundledUvPlatformKey();
811
+ const expectedSha = platformKey ? BUNDLED_UV_SHA256[platformKey] : null;
812
+
813
+ // Fast path: warm install already on disk with correct hash.
814
+ if (expectedSha && fs.existsSync(MANAGED_UV_BIN)) {
815
+ try {
816
+ const actual = await sha256File(MANAGED_UV_BIN);
817
+ if (actual === expectedSha) {
818
+ log(`Managed uv at ${MANAGED_UV_BIN} passed SHA256 check — reusing`);
819
+ addManagedBinToPath();
820
+ report(STAGES.ENSURE_UV, 100, "uv ready (cached)");
821
+ return;
685
822
  }
823
+ log(
824
+ `Managed uv hash mismatch (expected ${expectedSha}, got ${actual}) — replacing`
825
+ );
826
+ } catch (err) {
827
+ log(`Could not verify managed uv: ${err.message} — replacing`);
686
828
  }
687
829
  }
688
830
 
689
- if (!commandExists("uv")) {
690
- throw new InstallError(
691
- "uv installed but not found on PATH. A shell restart may be required.",
692
- {
693
- stage: STAGES.ENSURE_UV,
694
- suggestion:
695
- "Restart your terminal or reboot, then re-launch GAIA. If the problem persists, install uv manually from https://astral.sh/uv",
831
+ // Bundled path (the shipped-user path — AppImage, NSIS, DMG).
832
+ const bundled = findBundledUvResource();
833
+ if (bundled && platformKey) {
834
+ report(STAGES.ENSURE_UV, 30, "Installing bundled uv");
835
+ log(`Using bundled uv from ${bundled}`);
836
+
837
+ // Verify the source resource matches the manifest before copying
838
+ // catches AppImage corruption before we touch the user's home.
839
+ const srcHash = await sha256File(bundled);
840
+ if (srcHash !== expectedSha) {
841
+ throw new InstallError(
842
+ `Bundled uv resource SHA256 mismatch (expected ${expectedSha}, got ${srcHash}).`,
843
+ {
844
+ stage: STAGES.ENSURE_UV,
845
+ suggestion:
846
+ "The installer appears to be corrupt. Re-download GAIA from https://amd-gaia.ai and try again.",
847
+ }
848
+ );
849
+ }
850
+ await installBundledUv(bundled, platformKey);
851
+ addManagedBinToPath();
852
+ report(STAGES.ENSURE_UV, 100, "uv installed (bundled)");
853
+ return;
854
+ }
855
+
856
+ // DEV-ONLY fallback for contributors running from source (no
857
+ // extraResources, no packaged app). Never fires for end users.
858
+ const isDev = isPackaged === false || !process.resourcesPath;
859
+ if (isDev) {
860
+ if (commandExists("uv")) {
861
+ log("uv already on PATH (dev) — using system install");
862
+ report(STAGES.ENSURE_UV, 100, "uv is already installed (system)");
863
+ return;
864
+ }
865
+
866
+ log("[dev] No bundled uv and no system uv — falling back to curl|sh installer");
867
+ let result;
868
+ if (IS_WINDOWS) {
869
+ result = await runCommand(
870
+ "powershell",
871
+ [
872
+ "-ExecutionPolicy",
873
+ "Bypass",
874
+ "-Command",
875
+ "irm https://astral.sh/uv/install.ps1 | iex",
876
+ ],
877
+ { stageLabel: "uv-install-dev" }
878
+ );
879
+ } else {
880
+ result = await runCommand(
881
+ "bash",
882
+ ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
883
+ { stageLabel: "uv-install-dev" }
884
+ );
885
+ }
886
+
887
+ if (result.code !== 0) {
888
+ throw new InstallError(
889
+ `Could not install uv automatically (exit code ${result.code}).`,
890
+ {
891
+ stage: STAGES.ENSURE_UV,
892
+ code: result.code,
893
+ suggestion: IS_WINDOWS
894
+ ? 'Install uv manually: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
895
+ : "Install uv manually: curl -LsSf https://astral.sh/uv/install.sh | sh",
896
+ }
897
+ );
898
+ }
899
+
900
+ if (!commandExists("uv")) {
901
+ const candidates = [
902
+ path.join(os.homedir(), ".local", "bin"),
903
+ path.join(os.homedir(), ".cargo", "bin"),
904
+ ];
905
+ for (const uvDir of candidates) {
906
+ if (process.env.PATH && !process.env.PATH.includes(uvDir)) {
907
+ process.env.PATH = `${uvDir}${path.delimiter}${process.env.PATH}`;
908
+ log(`Added ${uvDir} to PATH for this process`);
909
+ }
696
910
  }
911
+ }
912
+
913
+ if (!commandExists("uv")) {
914
+ throw new InstallError(
915
+ "uv installed but not found on PATH. A shell restart may be required.",
916
+ {
917
+ stage: STAGES.ENSURE_UV,
918
+ suggestion:
919
+ "Restart your terminal or reboot, then re-launch GAIA. If the problem persists, install uv manually from https://astral.sh/uv",
920
+ }
921
+ );
922
+ }
923
+
924
+ report(STAGES.ENSURE_UV, 100, "uv installed (dev fallback)");
925
+ return;
926
+ }
927
+
928
+ // Packaged build, but we somehow don't have a bundled binary for this
929
+ // platform AND no system uv. Last-ditch: accept an unverified system uv
930
+ // if present; otherwise fail with a clear message.
931
+ if (commandExists("uv")) {
932
+ log(
933
+ `No bundled uv for ${process.platform}-${process.arch}, using system uv on PATH (unverified)`
697
934
  );
935
+ report(STAGES.ENSURE_UV, 100, "uv ready (system, unverified)");
936
+ return;
698
937
  }
699
938
 
700
- report(STAGES.ENSURE_UV, 100, "uv installed");
701
- log("uv install complete");
939
+ throw new InstallError(
940
+ `No bundled uv available for ${process.platform}-${process.arch} and no system uv found.`,
941
+ {
942
+ stage: STAGES.ENSURE_UV,
943
+ suggestion:
944
+ "Install uv manually from https://astral.sh/uv and re-launch GAIA.",
945
+ }
946
+ );
702
947
  }
703
948
 
704
949
  // ── Backend install ──────────────────────────────────────────────────────────
705
950
 
706
951
  /**
707
952
  * Read the pinned backend version from package.json (or a caller override).
953
+ * Returns null when GAIA_LOCAL_WHEEL is set — the caller uses the wheel path
954
+ * directly and skips the PyPI version pin (CI release-build fast-path).
708
955
  */
709
956
  function resolveBackendVersion(opts = {}) {
710
957
  if (opts.version) return opts.version;
958
+ // CI override: install from a local wheel instead of a pinned PyPI version.
959
+ // Breaks the circular dependency in release builds where the AppImage smoke
960
+ // test runs before PyPI publish.
961
+ if (process.env.GAIA_LOCAL_WHEEL) return null;
711
962
  try {
712
963
  // package.json is one directory up from the services/ (or bin/) directory.
713
964
  // We look relative to this module's own location.
@@ -734,7 +985,16 @@ function resolveBackendVersion(opts = {}) {
734
985
  async function installBackend(opts = {}) {
735
986
  const report = makeProgressReporter(opts.onProgress);
736
987
  const version = resolveBackendVersion(opts);
737
- const pipPackage = `amd-gaia[ui]==${version}`;
988
+ // GAIA_LOCAL_WHEEL: CI-only. When set, install from the given wheel path
989
+ // instead of pulling from PyPI. This breaks the circular dependency in
990
+ // release pipeline smoke tests that run before PyPI publish. The `[ui]`
991
+ // extras marker is preserved so the local install matches the PyPI path
992
+ // (fastapi, uvicorn, python-multipart, httpx, psutil) — otherwise the
993
+ // backend venv comes up missing every UI dep and /api/health never binds.
994
+ const localWheel = process.env.GAIA_LOCAL_WHEEL || null;
995
+ const pipPackage = localWheel
996
+ ? `${localWheel}[ui]`
997
+ : `amd-gaia[ui]==${version}`;
738
998
 
739
999
  log("================================================");
740
1000
  log(" Installing GAIA backend");
@@ -745,7 +1005,7 @@ async function installBackend(opts = {}) {
745
1005
  setState(STATES.INSTALLING, { stage: STAGES.ENSURE_UV, version });
746
1006
 
747
1007
  // Stage 1: ensure uv
748
- await ensureUv({ onProgress: opts.onProgress });
1008
+ await ensureUv({ onProgress: opts.onProgress, isPackaged: opts.isPackaged });
749
1009
 
750
1010
  // Stage 2: create venv
751
1011
  setState(STATES.INSTALLING, { stage: STAGES.CREATE_VENV, version });
@@ -800,7 +1060,8 @@ async function installBackend(opts = {}) {
800
1060
  GAIA_PYTHON_BIN,
801
1061
  ];
802
1062
  // Linux/macOS: use CPU-only PyTorch to avoid huge CUDA wheels.
803
- if (!IS_WINDOWS) {
1063
+ // Skip when installing from a local wheel — PyPI index not needed.
1064
+ if (!IS_WINDOWS && !localWheel) {
804
1065
  pipArgs.push("--extra-index-url", "https://download.pytorch.org/whl/cpu");
805
1066
  }
806
1067
 
@@ -979,11 +1240,13 @@ async function ensureBackend(opts = {}) {
979
1240
  }
980
1241
 
981
1242
  // Fast-path: already installed at the expected version.
1243
+ // Skip when expectedVersion is null (GAIA_LOCAL_WHEEL is set) — always
1244
+ // reinstall from the local wheel so CI gets a fresh install each run.
982
1245
  const expectedVersion = resolveBackendVersion(opts);
983
1246
  const existingBin = findGaiaBin();
984
1247
  if (existingBin) {
985
1248
  const installedVersion = getInstalledVersion(existingBin);
986
- if (installedVersion === expectedVersion) {
1249
+ if (expectedVersion !== null && installedVersion === expectedVersion) {
987
1250
  log(
988
1251
  `GAIA backend already installed at version ${installedVersion} — nothing to do`
989
1252
  );