@amd-gaia/agent-ui 0.17.5 → 0.17.6

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.
package/main.cjs CHANGED
@@ -17,7 +17,7 @@ const { app, BrowserWindow, dialog, shell } = require("electron");
17
17
  const path = require("path");
18
18
  const fs = require("fs");
19
19
  const os = require("os");
20
- const { spawn } = require("child_process");
20
+ const { spawn, spawnSync } = require("child_process");
21
21
  const { pathToFileURL } = require("url");
22
22
 
23
23
  // ── Shared log path ───────────────────────────────────────────────────────────
@@ -248,6 +248,70 @@ async function startBackend() {
248
248
  backendPort = DEFAULT_BACKEND_PORT;
249
249
  }
250
250
  healthCheckUrl = `http://localhost:${backendPort}/api/health`;
251
+ // Clean up any stale backend PID left from previous runs. The AppImage
252
+ // may be double-clicked multiple times or crash, leaving orphaned
253
+ // backend processes. We store a PID file under ~/.gaia/backend.pid and
254
+ // attempt to SIGTERM any still-running PID before starting a new backend.
255
+ try {
256
+ const pidFile = path.join(_GAIA_DIR, "backend.pid");
257
+ if (fs.existsSync(pidFile)) {
258
+ try {
259
+ const existingPid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
260
+ if (!Number.isNaN(existingPid)) {
261
+ console.log(`[main] Found existing backend pidfile (${existingPid}) — verifying process identity`);
262
+
263
+ // Verify the PID belongs to a GAIA backend before signalling it.
264
+ // Prefer a conservative match on the process command to avoid
265
+ // accidentally killing unrelated user processes (TOCTOU mitigation).
266
+ let isBackend = false;
267
+ try {
268
+ if (process.platform === "linux") {
269
+ const procCmd = `/proc/${existingPid}/cmdline`;
270
+ if (fs.existsSync(procCmd)) {
271
+ const raw = fs.readFileSync(procCmd, "utf8");
272
+ // /proc/<pid>/cmdline is NUL-separated; use a stricter match to
273
+ // avoid killing unrelated Python processes (Jupyter, LSP, etc.).
274
+ // Legitimate backend uses: `gaia chat --ui --ui-port <port>`
275
+ const looksLikeGaiaBackend = raw.includes("gaia") && (raw.includes("chat") || raw.includes("--ui-port"));
276
+ if (looksLikeGaiaBackend) {
277
+ isBackend = true;
278
+ }
279
+ }
280
+ } else if (process.platform === "darwin") {
281
+ try {
282
+ const out = spawnSync("ps", ["-p", String(existingPid), "-o", "command="], { encoding: "utf8" });
283
+ const cmd = (out && out.stdout) ? out.stdout : "";
284
+ const looksLikeGaiaBackend = cmd.includes("gaia") && (cmd.includes("chat") || cmd.includes("--ui-port"));
285
+ if (looksLikeGaiaBackend) {
286
+ isBackend = true;
287
+ }
288
+ } catch {
289
+ // fallthrough
290
+ }
291
+ }
292
+ } catch (err) {
293
+ console.warn(`[main] Could not verify pid ${existingPid}: ${err.message}`);
294
+ }
295
+
296
+ if (!isBackend) {
297
+ console.log(`[main] PID ${existingPid} does not appear to be a GAIA backend; skipping kill`);
298
+ } else {
299
+ try {
300
+ await portManager.killBackend(existingPid);
301
+ console.log(`[main] Cleaned up previous backend pid ${existingPid}`);
302
+ } catch (err) {
303
+ console.warn(`[main] Could not clean previous backend pid ${existingPid}: ${err.message}`);
304
+ }
305
+ }
306
+ }
307
+ } catch (err) {
308
+ console.warn(`[main] Failed reading backend pidfile: ${err.message}`);
309
+ }
310
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
311
+ }
312
+ } catch (err) {
313
+ console.warn(`[main] PID cleanup check failed: ${err.message}`);
314
+ }
251
315
 
252
316
  console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${backendPort}`);
253
317
 
@@ -290,6 +354,18 @@ async function startBackend() {
290
354
  console.error("Failed to start backend:", err.message);
291
355
  });
292
356
 
357
+ // Write a PID file so subsequent AppImage launches can detect and
358
+ // cleanup this backend if it becomes orphaned. The pidfile is removed
359
+ // when the child exits.
360
+ try {
361
+ try { fs.mkdirSync(_GAIA_DIR, { recursive: true }); } catch { /* ignore */ }
362
+ const pidFile = path.join(_GAIA_DIR, "backend.pid");
363
+ fs.writeFileSync(pidFile, String(child.pid), { mode: 0o600 });
364
+ console.log(`[main] Wrote backend pidfile ${pidFile} (pid=${child.pid})`);
365
+ } catch (err) {
366
+ console.warn(`[main] Could not write backend pidfile: ${err.message}`);
367
+ }
368
+
293
369
  child.on("exit", (code, signal) => {
294
370
  if (code !== 0 && code !== null) {
295
371
  console.error(`Backend exited with code ${code} (signal=${signal})`);
@@ -301,6 +377,20 @@ async function startBackend() {
301
377
  // Fire-and-forget — don't block the event loop.
302
378
  void handleBackendCrash(code, signal);
303
379
  }
380
+
381
+ // Remove pidfile on exit to avoid leaving stale PID behind.
382
+ try {
383
+ const pidFile = path.join(_GAIA_DIR, "backend.pid");
384
+ if (fs.existsSync(pidFile)) {
385
+ const p = fs.readFileSync(pidFile, "utf8").trim();
386
+ if (p && parseInt(p, 10) === child.pid) {
387
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
388
+ }
389
+ }
390
+ } catch (err) {
391
+ // Non-fatal — just log and continue.
392
+ console.warn(`[main] Failed to remove backend pidfile: ${err.message}`);
393
+ }
304
394
  });
305
395
 
306
396
  return child;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.17.5",
3
+ "version": "0.17.6",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
6
  "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",
@@ -345,55 +345,89 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
345
345
  .filter(Boolean)
346
346
  .join("\n");
347
347
 
348
- const result = await dialog.showMessageBox(parentWindow || null, {
349
- type: "error",
350
- title: "GAIA install failed",
351
- message,
352
- detail,
353
- buttons: [
354
- "Retry",
355
- "Manual install instructions",
356
- "Copy log path",
357
- "Open log file",
358
- "Quit",
359
- ],
360
- defaultId: 0,
361
- cancelId: 4,
362
- noLink: true,
363
- });
364
-
365
- switch (result.response) {
366
- case 0:
367
- return "retry";
368
- case 1: {
369
- try {
370
- await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
371
- } catch {
372
- // ignore
348
+ // Loop so that certain actions (Copy/Open log) return control to the
349
+ // same dialog rather than exiting the flow. The dialog includes a
350
+ // one-click "Install uv" action which attempts the packaged rescue
351
+ // installer and then returns 'retry' on success so the caller can
352
+ // re-run the full backend install.
353
+ const buttons = [
354
+ "Install uv (auto)",
355
+ "Retry",
356
+ "Manual install instructions",
357
+ "Copy log path",
358
+ "Open log file",
359
+ "Quit",
360
+ ];
361
+
362
+ // Helper to show the dialog and handle the response.
363
+ const show = async () => {
364
+ const result = await dialog.showMessageBox(parentWindow || null, {
365
+ type: "error",
366
+ title: "GAIA install failed",
367
+ message,
368
+ detail,
369
+ buttons,
370
+ defaultId: 0,
371
+ cancelId: buttons.length - 1,
372
+ noLink: true,
373
+ });
374
+
375
+ switch (result.response) {
376
+ case 0: {
377
+ // Run the full packaged install flow (ensureBackend) so the user
378
+ // gets a single-click recovery that attempts the entire backend
379
+ // install rather than only uv provisioning. Show progress UI
380
+ // while the operation runs. On success, tell the caller to retry
381
+ // (which will detect READY and proceed); on failure, re-show the
382
+ // dialog with augmented details.
383
+ const { window, onProgress, close } = createProgressWindow();
384
+ try {
385
+ await installer.ensureBackend({ onProgress, isPackaged: true });
386
+ try { close(); } catch {}
387
+ return "retry";
388
+ } catch (err) {
389
+ try { close(); } catch {}
390
+ const nextInfo = Object.assign({}, errorInfo, {
391
+ message: err.message || String(err),
392
+ stage: err.stage || "ensure-backend",
393
+ suggestion: err.suggestion || errorInfo.suggestion,
394
+ });
395
+ return showFailureDialog(parentWindow, nextInfo);
396
+ }
373
397
  }
374
- return "manual";
375
- }
376
- case 2: {
377
- try {
378
- clipboard.writeText(logPath);
379
- } catch {
380
- // ignore
398
+ case 1:
399
+ return "retry";
400
+ case 2: {
401
+ try {
402
+ await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
403
+ } catch {
404
+ // ignore
405
+ }
406
+ return "manual";
381
407
  }
382
- // Keep the user in the loop — re-show the dialog so they can pick an action.
383
- return showFailureDialog(parentWindow, errorInfo);
384
- }
385
- case 3: {
386
- try {
387
- await shell.openPath(logPath);
388
- } catch {
389
- // ignore
408
+ case 3: {
409
+ try {
410
+ clipboard.writeText(logPath);
411
+ } catch {
412
+ // ignore
413
+ }
414
+ return showFailureDialog(parentWindow, errorInfo);
415
+ }
416
+ case 4: {
417
+ try {
418
+ await shell.openPath(logPath);
419
+ } catch {
420
+ // ignore
421
+ }
422
+ return showFailureDialog(parentWindow, errorInfo);
390
423
  }
391
- return showFailureDialog(parentWindow, errorInfo);
424
+ case 5:
425
+ default:
426
+ return "quit";
392
427
  }
393
- case 4:
394
- default:
395
- return "quit";
396
- }
428
+ };
429
+
430
+ return show();
397
431
  }
398
432
 
399
433
  // ── Pre-check failure dialogs ───────────────────────────────────────────────
@@ -77,10 +77,28 @@ const NETWORK_CHECK_TIMEOUT_MS = 5000;
77
77
  // archive against upstream's published .sha256, then extracts the `uv` binary
78
78
  // which is what `ensureUv()` hashes at runtime.
79
79
  //
80
- // Currently pinned: uv v0.5.14 linux-x64.
80
+ // Currently pinned: uv v0.5.14 linux-x64, mac-arm64.
81
+ // (win-x64 deferred to a follow-up issue — its SHA must ship together with an
82
+ // NSIS structural-smoke verifier, not on its own; see the #849 lesson.)
83
+ //
84
+ // IMPORTANT: per-platform SHA origin differs:
85
+ // - linux-x64: raw extracted-from-tarball digest (no post-build modification).
86
+ // - mac-arm64: POST-CODESIGN digest. electron-builder code-signs the bundled
87
+ // uv during packaging, so this hash matches what ensureUv() sees
88
+ // at runtime, NOT the upstream tarball. Bumping this pin means
89
+ // running the CI build, then copying the SHA from the
90
+ // dmg-structural-smoke failure message — never from `shasum`
91
+ // against the freshly downloaded tarball.
81
92
  const BUNDLED_UV_VERSION = "0.5.14";
82
93
  const BUNDLED_UV_SHA256 = {
83
94
  "linux-x64": "0e05d828b5708e8a927724124db3746396afddad6273c47283d7c562dc795bd6",
95
+ // The Windows extracted uv.exe SHA is populated by CI during the
96
+ // build step. The placeholder MUST be replaced in CI before packaging
97
+ // so runtime verification remains strict.
98
+ "win-x64": "055d55eec85a91cfb5e9c8bc7f6463f9883866796c5bcb205fbcdfed9c088c88",
99
+ // mac-arm64: POST-codesign digest. CI should populate this value when
100
+ // packaging the macOS DMG and running the dmg-structural-smoke job.
101
+ "mac-arm64": "6099aa8cd701f0c81227ee30c304777ce151e4d47c53a75ce53cd2243448d8c8",
84
102
  };
85
103
 
86
104
  const MANAGED_UV_DIR = path.join(GAIA_HOME, "bin");
@@ -697,9 +715,12 @@ function findBundledUvResource() {
697
715
  */
698
716
  async function installBundledUv(sourcePath, platformKey) {
699
717
  const expected = BUNDLED_UV_SHA256[platformKey];
700
- if (!expected) {
718
+ if (!expected || expected.startsWith("<")) {
719
+ // Enforce strict verification: builds MUST populate the expected SHA
720
+ // for packaged binaries. Failing fast prevents shipping an unverified
721
+ // uv binary which would be a supply-chain regression.
701
722
  throw new InstallError(
702
- `No bundled uv checksum registered for platform ${platformKey}.`,
723
+ `No bundled uv checksum registered for platform ${platformKey}. Build must populate BUNDLED_UV_SHA256.${platformKey}`,
703
724
  { stage: STAGES.ENSURE_UV }
704
725
  );
705
726
  }
@@ -741,16 +762,20 @@ async function installBundledUv(sourcePath, platformKey) {
741
762
  );
742
763
  }
743
764
 
744
- if (actual !== expected) {
745
- try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
746
- throw new InstallError(
747
- `Bundled uv SHA256 mismatch (expected ${expected}, got ${actual}).`,
748
- {
749
- stage: STAGES.ENSURE_UV,
750
- suggestion:
751
- "The AppImage/installer may be corrupt. Re-download from https://amd-gaia.ai and try again.",
752
- }
753
- );
765
+ if (expected) {
766
+ if (actual !== expected) {
767
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
768
+ throw new InstallError(
769
+ `Bundled uv SHA256 mismatch (expected ${expected}, got ${actual}).`,
770
+ {
771
+ stage: STAGES.ENSURE_UV,
772
+ suggestion:
773
+ "The AppImage/installer may be corrupt. Re-download from https://amd-gaia.ai and try again.",
774
+ }
775
+ );
776
+ }
777
+ } else {
778
+ log("No expected SHA registered for bundled uv; installed binary will not be verified locally.");
754
779
  }
755
780
 
756
781
  try {
@@ -836,6 +861,10 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
836
861
 
837
862
  // Verify the source resource matches the manifest before copying —
838
863
  // catches AppImage corruption before we touch the user's home.
864
+ // Enforce that the packaged build provides an expected SHA for the
865
+ // bundled resource. CI replaces the placeholder with the extracted
866
+ // binary's SHA during the build; missing/placeholder values are a
867
+ // build-time error and are rejected at runtime here.
839
868
  const srcHash = await sha256File(bundled);
840
869
  if (srcHash !== expectedSha) {
841
870
  throw new InstallError(
@@ -925,6 +954,52 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
925
954
  return;
926
955
  }
927
956
 
957
+ // Packaged Windows rescue: try the Astral PowerShell installer even when
958
+ // running a packaged build. This provides an automated recovery path for
959
+ // end users on clean machines that don't have uv and where the installer
960
+ // build unexpectedly omitted a bundled binary. It is a last-resort and
961
+ // non-fatal attempt; on failure we fall through to the generic error.
962
+ if (IS_WINDOWS && !isDev) {
963
+ log("Packaged Windows: attempting automated uv installer (rescue)");
964
+ try {
965
+ const rescue = await runCommand(
966
+ "powershell",
967
+ [
968
+ "-ExecutionPolicy",
969
+ "Bypass",
970
+ "-Command",
971
+ "irm https://astral.sh/uv/install.ps1 | iex",
972
+ ],
973
+ { stageLabel: "uv-install-packaged-rescue" }
974
+ );
975
+ if (rescue.code === 0) {
976
+ // Ensure common install locations are present on PATH for this
977
+ // process in case the installer placed uv in a user-local bin.
978
+ const candidates = [
979
+ path.join(os.homedir(), ".local", "bin"),
980
+ path.join(os.homedir(), ".cargo", "bin"),
981
+ ];
982
+ for (const uvDir of candidates) {
983
+ if (process.env.PATH && !process.env.PATH.includes(uvDir)) {
984
+ process.env.PATH = `${uvDir}${path.delimiter}${process.env.PATH}`;
985
+ log(`Added ${uvDir} to PATH for this process`);
986
+ }
987
+ }
988
+ if (commandExists("uv")) {
989
+ log("Packaged Windows: uv installed and found on PATH (rescue succeeded)");
990
+ addManagedBinToPath();
991
+ report(STAGES.ENSURE_UV, 100, "uv ready (system, unverified)");
992
+ return;
993
+ }
994
+ log("Packaged Windows: uv installer ran but uv not found on PATH");
995
+ } else {
996
+ log(`Packaged Windows: automated uv installer exited ${rescue.code}`);
997
+ }
998
+ } catch (rescueErr) {
999
+ log(`Packaged Windows: rescue installer threw: ${rescueErr.message}`);
1000
+ }
1001
+ }
1002
+
928
1003
  // Packaged build, but we somehow don't have a bundled binary for this
929
1004
  // platform AND no system uv. Last-ditch: accept an unverified system uv
930
1005
  // if present; otherwise fail with a clear message.
@@ -937,11 +1012,11 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
937
1012
  }
938
1013
 
939
1014
  throw new InstallError(
940
- `No bundled uv available for ${process.platform}-${process.arch} and no system uv found.`,
1015
+ `GAIA could not find or install its Python helper (uv) required to provision the backend.`,
941
1016
  {
942
1017
  stage: STAGES.ENSURE_UV,
943
1018
  suggestion:
944
- "Install uv manually from https://astral.sh/uv and re-launch GAIA.",
1019
+ "GAIA attempted automatic recovery but could not install uv. Please either: (a) click 'Install uv' in the dialog to let GAIA try again, or (b) install uv from https://astral.sh/uv and re-launch GAIA.",
945
1020
  }
946
1021
  );
947
1022
  }
@@ -1135,6 +1210,46 @@ async function installBackend(opts = {}) {
1135
1210
  log(`Verified gaia binary: ${verifiedBin} (version=${installedVersion || "unknown"})`);
1136
1211
  report(STAGES.VERIFY, 100, "Install verified");
1137
1212
 
1213
+ // Ensure a user-accessible shim is created so users who installed via
1214
+ // AppImage can run `gaia` from a terminal without manually adding the
1215
+ // venv bin directory to their PATH. Do not overwrite an existing system
1216
+ // `gaia` or an existing shim the user may have created.
1217
+ try {
1218
+ // Only create shims on POSIX-like systems (AppImage target).
1219
+ if (process.platform !== "win32") {
1220
+ const userBin = process.env.XDG_BIN_HOME || path.join(os.homedir(), ".local", "bin");
1221
+ const shimPath = path.join(userBin, "gaia");
1222
+
1223
+ // Only create a shim if there's no `gaia` already on PATH and no shim
1224
+ // at the target location. This avoids clobbering system packages.
1225
+ if (!commandExists("gaia") && !fs.existsSync(shimPath)) {
1226
+ try {
1227
+ // Basic sanity-check on the target path to avoid writing a
1228
+ // wrapper that could execute an arbitrary command. The
1229
+ // verifiedBin is produced by our installer and is expected to be
1230
+ // a normal filesystem path (alphanum, dash, dot, slash, underscore).
1231
+ if (!/^[\w\-./]+$/.test(verifiedBin)) {
1232
+ log(`Refusing to create shim: verified bin path looks suspicious: ${verifiedBin}`);
1233
+ } else {
1234
+ fs.mkdirSync(userBin, { recursive: true });
1235
+ const wrapper = `#!/bin/sh\nexec \"${verifiedBin}\" \"$@\"\n`;
1236
+ fs.writeFileSync(shimPath, wrapper, { mode: 0o755 });
1237
+ log(`Created user shim at ${shimPath} pointing to ${verifiedBin}`);
1238
+ }
1239
+ } catch (err) {
1240
+ log(`Could not create user shim at ${shimPath}: ${err.message}`);
1241
+ }
1242
+ } else if (fs.existsSync(shimPath)) {
1243
+ log(`User shim already exists at ${shimPath}; leaving it intact`);
1244
+ } else {
1245
+ log("A system 'gaia' binary was found on PATH; skipping shim creation");
1246
+ }
1247
+ }
1248
+ } catch (err) {
1249
+ // Non-fatal: proceed even if shim creation fails.
1250
+ log(`Shim creation check failed: ${err.message}`);
1251
+ }
1252
+
1138
1253
  setState(STATES.READY, { stage: null, version, installedVersion });
1139
1254
  log("Backend install complete");
1140
1255
  }