@amd-gaia/agent-ui 0.17.3 → 0.17.5

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
@@ -13,26 +13,153 @@
13
13
  // services/notification-service.js — Desktop notifications + permission prompts (T5)
14
14
  // preload.cjs — contextBridge for IPC channels (T0/T1)
15
15
 
16
- const { app, BrowserWindow, shell } = require("electron");
16
+ 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
20
  const { spawn } = require("child_process");
21
+ const { pathToFileURL } = require("url");
22
+
23
+ // ── Shared log path ───────────────────────────────────────────────────────────
24
+ // Single source of truth used by installSafetyNet AND installMainLogTee so
25
+ // both write to the same file without independent path computations that
26
+ // could drift apart.
27
+ const _GAIA_DIR = path.join(os.homedir(), ".gaia");
28
+ const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log");
29
+
30
+ // ── Safety net (issue #934) ───────────────────────────────────────────────────
31
+ // Install top-level error handlers BEFORE any service module is required so
32
+ // that synchronous throws at module-load time are caught and shown as a
33
+ // GAIA-branded error box instead of Electron's bare JS-error dialog.
34
+ // Extracted into main-safety-net.cjs so tests can require it without
35
+ // triggering main.cjs side effects (Electron modules, service requires).
36
+ // Wrapped in try/catch: a corrupt ASAR or bad path would otherwise bypass the
37
+ // very handler we are trying to install, falling through to Electron's bare
38
+ // JS-error dialog.
39
+ let installSafetyNet, installLogTee, _fatalHandler;
40
+ try {
41
+ ({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs"));
42
+ ({ fatal: _fatalHandler } = installSafetyNet({
43
+ logPath: _MAIN_LOG_PATH,
44
+ dialogModule: dialog,
45
+ appModule: app,
46
+ }));
47
+ } catch (err) {
48
+ try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { }
49
+ try { dialog.showErrorBox("GAIA failed to start", String((err && err.stack) || err)); } catch { }
50
+ // Synchronous exit: service module requires below have no uncaughtException
51
+ // handler installed, so execution cannot safely continue.
52
+ process.exit(1);
53
+ }
21
54
 
22
55
  // Services (loaded after app.whenReady)
23
56
  const TrayManager = require("./services/tray-manager.cjs");
24
57
  const AgentProcessManager = require("./services/agent-process-manager.cjs");
25
58
  const NotificationService = require("./services/notification-service.cjs");
59
+ const PortManager = require("./services/port-manager.cjs");
60
+ const { buildIndexQuery } = require("./services/index-query.cjs");
26
61
  const backendInstaller = require("./services/backend-installer.cjs");
27
62
  const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
28
63
  const autoUpdater = require("./services/auto-updater.cjs");
29
64
  const agentSeeder = require("./services/agent-seeder.cjs");
30
65
 
66
+ // ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
67
+ // Electron-recommended switch for distro-agnostic Linux behaviour: picks
68
+ // Wayland on Wayland sessions, X11 elsewhere. Must be set before
69
+ // app.whenReady() fires.
70
+ app.commandLine.appendSwitch("ozone-platform-hint", "auto");
71
+
72
+ // ── F7: --no-sandbox on Linux (issue #782) ───────────────────────────────────
73
+ // chrome-sandbox is deleted from the packaged tree by after-pack.cjs so
74
+ // Chromium must use its unprivileged user-namespace sandbox on every launch
75
+ // path. The .desktop Exec= line already carries --no-sandbox via
76
+ // electron-builder.yml linux.executableArgs, but direct `./GAIA.AppImage`
77
+ // invocations bypass the .desktop entry. Appending the switch here makes
78
+ // all Linux launch paths behave identically.
79
+ if (process.platform === "linux") {
80
+ app.commandLine.appendSwitch("no-sandbox");
81
+ }
82
+
83
+ // ── F7: Log tee to ~/.gaia/electron-main.log (issue #782) ───────────────────
84
+ // Users often launch AppImages by double-click, not from a terminal, so
85
+ // console output vanishes. Mirror console.log/error to a file so the
86
+ // diagnostics bundler has something to attach.
87
+ (function installMainLogTee() {
88
+ try {
89
+ try { fs.mkdirSync(_GAIA_DIR, { recursive: true }); } catch { /* ignore */ }
90
+ const logPath = _MAIN_LOG_PATH;
91
+
92
+ // Rotate if > 5 MB — truncate to last ~5 MB on startup.
93
+ try {
94
+ const st = fs.statSync(logPath);
95
+ if (st.size > 5 * 1024 * 1024) {
96
+ const fd = fs.openSync(logPath, "r");
97
+ const keep = 5 * 1024 * 1024;
98
+ const buf = Buffer.alloc(keep);
99
+ // readSync can return fewer bytes than requested; only write
100
+ // what we actually read so we don't append zero-padding.
101
+ const bytesRead = fs.readSync(
102
+ fd,
103
+ buf,
104
+ 0,
105
+ keep,
106
+ Math.max(0, st.size - keep),
107
+ );
108
+ fs.closeSync(fd);
109
+ fs.writeFileSync(logPath, buf.subarray(0, bytesRead));
110
+ }
111
+ } catch {
112
+ // ENOENT or permission — best-effort; just fall through.
113
+ }
114
+
115
+ const stream = fs.createWriteStream(logPath, { flags: "a" });
116
+ // Root-cause fix for #934: stream.write() after end emits 'error'
117
+ // asynchronously — the try/catch in wrap() below doesn't catch it.
118
+ // This listener absorbs the event before it becomes uncaughtException.
119
+ installLogTee({ stream, logPath });
120
+ stream.write(
121
+ `\n──── electron-main opened (${new Date().toISOString()}) pid=${process.pid} ────\n`
122
+ );
123
+
124
+ const flushAndEnd = () => {
125
+ try { stream.end(); } catch { /* ignore */ }
126
+ };
127
+ process.on("exit", flushAndEnd);
128
+
129
+ const wrap = (origFn, level) => (...args) => {
130
+ try {
131
+ const line = args
132
+ .map((a) =>
133
+ typeof a === "string"
134
+ ? a
135
+ : a instanceof Error
136
+ ? (a.stack || a.message || String(a))
137
+ : JSON.stringify(a)
138
+ )
139
+ .join(" ");
140
+ stream.write(`[${new Date().toISOString()}] ${level} ${line}\n`);
141
+ } catch {
142
+ // swallow — we must not recurse into console.error from here
143
+ }
144
+ return origFn.apply(console, args);
145
+ };
146
+ console.log = wrap(console.log.bind(console), "INFO");
147
+ console.warn = wrap(console.warn.bind(console), "WARN");
148
+ console.error = wrap(console.error.bind(console), "ERROR");
149
+ } catch (err) {
150
+ // If this fails, we silently keep the original console — the app must
151
+ // not refuse to launch over a log-tee failure.
152
+ process.stderr.write(`[main] log tee failed: ${err.message}\n`);
153
+ }
154
+ })();
155
+
31
156
  // ── Configuration ──────────────────────────────────────────────────────────
32
157
 
33
158
  const APP_NAME = "GAIA";
34
- const BACKEND_PORT = 4200;
35
- const HEALTH_CHECK_URL = `http://localhost:${BACKEND_PORT}/api/health`;
159
+ // Default fallback only — at runtime we always allocate a free random port
160
+ // via PortManager.findFreePort() to avoid EADDRINUSE on zombie backends
161
+ // from prior aborted sessions (issue #782 / T5).
162
+ const DEFAULT_BACKEND_PORT = 4200;
36
163
  const STARTUP_TIMEOUT = 30000;
37
164
 
38
165
  // Parse CLI args (T11: --minimized flag for auto-start)
@@ -59,8 +186,17 @@ const windowConfig = appConfig.window || {
59
186
  // ── State ──────────────────────────────────────────────────────────────────
60
187
 
61
188
  let backendProcess = null;
189
+ let backendPort = DEFAULT_BACKEND_PORT;
190
+ let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
191
+ let backendStderrTail = [];
192
+ let isIntentionalKill = false;
62
193
  let mainWindow = null;
63
194
 
195
+ // True until createWindow() runs. Guards window-all-closed from firing app.quit()
196
+ // while the backend-installer progress dialog is open (it's the only window during
197
+ // bootstrap, so destroying it would trigger a premature quit — issue #934).
198
+ let isBootstrapping = true;
199
+
64
200
  /** @type {TrayManager | null} */
65
201
  let trayManager = null;
66
202
 
@@ -70,6 +206,11 @@ let agentProcessManager = null;
70
206
  /** @type {NotificationService | null} */
71
207
  let notificationService = null;
72
208
 
209
+ /** @type {PortManager} */
210
+ const portManager = new PortManager({
211
+ logger: { log: console.log.bind(console), error: console.error.bind(console) },
212
+ });
213
+
73
214
  /**
74
215
  * Set to true when the user explicitly quits (via tray "Quit" or Cmd+Q).
75
216
  * Prevents minimize-to-tray from intercepting the close event.
@@ -86,7 +227,7 @@ let isQuitting = false;
86
227
  * Returns the ChildProcess, or null if the gaia binary cannot be found
87
228
  * (shouldn't happen post-ensureBackend, but we guard just in case).
88
229
  */
89
- function startBackend() {
230
+ async function startBackend() {
90
231
  const gaiaCmd = backendInstaller.findGaiaBin();
91
232
 
92
233
  if (!gaiaCmd) {
@@ -96,11 +237,27 @@ function startBackend() {
96
237
  return null;
97
238
  }
98
239
 
99
- console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${BACKEND_PORT}`);
240
+ // F5: always spawn on a free random port. Never reuse/probe — the
241
+ // probe-and-reuse path is spoofable and leaves orphans (issue #782).
242
+ try {
243
+ backendPort = await portManager.findFreePort();
244
+ } catch (err) {
245
+ console.warn(
246
+ `[main] findFreePort failed (${err.message}); falling back to ${DEFAULT_BACKEND_PORT}`
247
+ );
248
+ backendPort = DEFAULT_BACKEND_PORT;
249
+ }
250
+ healthCheckUrl = `http://localhost:${backendPort}/api/health`;
251
+
252
+ console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${backendPort}`);
253
+
254
+ // Reset per-spawn state so a fresh crash dialog doesn't mix tails.
255
+ backendStderrTail = [];
256
+ isIntentionalKill = false;
100
257
 
101
258
  const child = spawn(
102
259
  gaiaCmd,
103
- ["chat", "--ui", "--ui-port", String(BACKEND_PORT)],
260
+ ["chat", "--ui", "--ui-port", String(backendPort)],
104
261
  {
105
262
  cwd: os.homedir(), // Electron's cwd is "/" on macOS when launched from Finder
106
263
  stdio: ["ignore", "pipe", "pipe"],
@@ -116,24 +273,85 @@ function startBackend() {
116
273
  });
117
274
 
118
275
  child.stderr.on("data", (data) => {
119
- const line = data.toString().trim();
120
- if (line) console.log(`[backend] ${line}`);
276
+ const chunk = data.toString();
277
+ chunk.split(/\r?\n/).forEach((line) => {
278
+ if (!line) return;
279
+ console.log(`[backend] ${line}`);
280
+ // Cap per-line length so pathological no-newline backend output
281
+ // can't balloon the in-memory tail or the crash-dialog body.
282
+ const capped =
283
+ line.length > 2048 ? line.slice(0, 2048) + "…[truncated]" : line;
284
+ backendStderrTail.push(capped);
285
+ if (backendStderrTail.length > 20) backendStderrTail.shift();
286
+ });
121
287
  });
122
288
 
123
289
  child.on("error", (err) => {
124
290
  console.error("Failed to start backend:", err.message);
125
291
  });
126
292
 
127
- child.on("exit", (code) => {
293
+ child.on("exit", (code, signal) => {
128
294
  if (code !== 0 && code !== null) {
129
- console.error(`Backend exited with code ${code}`);
295
+ console.error(`Backend exited with code ${code} (signal=${signal})`);
130
296
  }
297
+ const crashed = !isIntentionalKill && code !== 0 && code !== null;
131
298
  backendProcess = null;
299
+
300
+ if (crashed && !isQuitting) {
301
+ // Fire-and-forget — don't block the event loop.
302
+ void handleBackendCrash(code, signal);
303
+ }
132
304
  });
133
305
 
134
306
  return child;
135
307
  }
136
308
 
309
+ /**
310
+ * Show a user-facing crash dialog with the last ~20 stderr lines and
311
+ * offer to write a diagnostics bundle. Invoked from child.on("exit")
312
+ * when the kill was NOT initiated by us.
313
+ */
314
+ async function handleBackendCrash(code, signal) {
315
+ const tail = backendStderrTail.slice(-20).join("\n") || "(no stderr captured)";
316
+ const detail =
317
+ `The GAIA backend exited unexpectedly (code=${code}, signal=${signal}).\n\n` +
318
+ `Recent log output:\n${tail}`;
319
+
320
+ try {
321
+ const choice = await dialog.showMessageBox({
322
+ type: "error",
323
+ title: "GAIA backend crashed",
324
+ message: "GAIA backend crashed",
325
+ detail,
326
+ buttons: ["Copy diagnostics", "Quit"],
327
+ defaultId: 0,
328
+ cancelId: 1,
329
+ noLink: true,
330
+ });
331
+
332
+ if (choice.response === 0) {
333
+ try {
334
+ const bundlePath = await portManager.writeDiagnosticsBundle();
335
+ await dialog.showMessageBox({
336
+ type: "info",
337
+ title: "Diagnostics saved",
338
+ message: "Diagnostics bundle written",
339
+ detail: `Attach this file to your bug report:\n${bundlePath}`,
340
+ buttons: ["OK"],
341
+ });
342
+ } catch (err) {
343
+ console.error("[main] Could not write diagnostics bundle:", err.message);
344
+ dialog.showErrorBox(
345
+ "Could not write diagnostics",
346
+ `Failed to write diagnostics bundle: ${err.message}`
347
+ );
348
+ }
349
+ }
350
+ } catch (err) {
351
+ console.error("[main] handleBackendCrash failed:", err.message);
352
+ }
353
+ }
354
+
137
355
  async function waitForBackend(timeoutMs) {
138
356
  const start = Date.now();
139
357
  const http = require("http");
@@ -141,7 +359,7 @@ async function waitForBackend(timeoutMs) {
141
359
  while (Date.now() - start < timeoutMs) {
142
360
  try {
143
361
  await new Promise((resolve, reject) => {
144
- const req = http.get(HEALTH_CHECK_URL, (res) => {
362
+ const req = http.get(healthCheckUrl, (res) => {
145
363
  if (res.statusCode === 200) {
146
364
  resolve();
147
365
  } else {
@@ -221,10 +439,12 @@ function createWindow() {
221
439
 
222
440
  // Show window when ready (unless --minimized or startMinimized config)
223
441
  mainWindow.once("ready-to-show", () => {
442
+ console.log("[main] ready-to-show fired");
224
443
  const shouldStartMinimized =
225
444
  startMinimized || (trayManager && trayManager.startMinimized);
226
445
 
227
446
  if (!shouldStartMinimized) {
447
+ console.log("[main] mainWindow.show() called");
228
448
  mainWindow.show();
229
449
  } else {
230
450
  console.log("[main] Starting minimized to tray");
@@ -241,9 +461,20 @@ async function loadApp() {
241
461
  // Always load the bundled frontend from the asar. The backend only
242
462
  // serves the API (no frontend files in the pip package), so loading
243
463
  // http://localhost:4200/ would show raw JSON instead of the UI.
464
+ //
465
+ // Pass the real backend base URL as a query parameter so the renderer
466
+ // can reach whatever random port port-manager picked (see #851). The
467
+ // renderer (apiBase.ts) validates this value against an allowlist
468
+ // before using it — keep buildIndexQuery in sync with TRUSTED_API_RE.
244
469
  const indexPath = path.join(distPath, "index.html");
245
- console.log("Loading app from:", indexPath);
246
- await mainWindow.loadFile(indexPath);
470
+ const indexQuery = buildIndexQuery(backendPort);
471
+ console.log("Loading app from:", indexPath, "api:", indexQuery.api);
472
+ // Use pathToFileURL so the file:// URL always has forward slashes on
473
+ // Windows — Chromium 130+ (Electron 40) rejects backslash file URLs
474
+ // that Node's url.format() (used by loadFile) produces on Windows.
475
+ const fileUrl = pathToFileURL(indexPath);
476
+ fileUrl.search = new URLSearchParams(indexQuery).toString();
477
+ await mainWindow.loadURL(fileUrl.href);
247
478
  } else {
248
479
  // Show a simple loading/error page
249
480
  mainWindow.loadURL(
@@ -254,7 +485,7 @@ async function loadApp() {
254
485
  <div style="text-align:center;">
255
486
  <h1>${APP_NAME}</h1>
256
487
  <p>Waiting for backend to start...</p>
257
- <p style="color:#888; font-size:12px;">Backend: http://localhost:${BACKEND_PORT}</p>
488
+ <p style="color:#888; font-size:12px;">Backend: http://localhost:${backendPort}</p>
258
489
  </div>
259
490
  </body>
260
491
  </html>`
@@ -271,7 +502,7 @@ function initializeServices() {
271
502
  agentProcessManager = new AgentProcessManager(mainWindow);
272
503
 
273
504
  // T1: Tray Manager (system tray icon + context menu)
274
- trayManager = new TrayManager(mainWindow, { backendPort: BACKEND_PORT });
505
+ trayManager = new TrayManager(mainWindow, { backendPort });
275
506
  trayManager.create();
276
507
 
277
508
  // T5: Notification Service (routes agent notifications to OS + renderer)
@@ -369,6 +600,7 @@ async function bootstrapBackend() {
369
600
  try {
370
601
  await backendInstaller.ensureBackend({
371
602
  onProgress: progress.onProgress,
603
+ isPackaged: app.isPackaged,
372
604
  });
373
605
  progress.close();
374
606
  console.log("[main] Backend bootstrap complete");
@@ -484,10 +716,11 @@ app.whenReady().then(async () => {
484
716
  }
485
717
 
486
718
  // Start the Python backend
487
- backendProcess = startBackend();
719
+ backendProcess = await startBackend();
488
720
 
489
721
  // Create the window (hidden until ready-to-show)
490
722
  createWindow();
723
+ isBootstrapping = false; // progress dialog is gone; window-all-closed may now quit
491
724
 
492
725
  // Initialize services (tray, agent manager, notifications)
493
726
  initializeServices();
@@ -520,7 +753,7 @@ app.whenReady().then(async () => {
520
753
  console.log("Waiting for backend to start...");
521
754
  const ready = await waitForBackend(STARTUP_TIMEOUT);
522
755
  if (ready) {
523
- console.log("Backend API is ready on port", BACKEND_PORT);
756
+ console.log("Backend API is ready on port", backendPort);
524
757
  } else {
525
758
  console.warn("Backend did not respond within timeout.");
526
759
  }
@@ -551,11 +784,21 @@ app.whenReady().then(async () => {
551
784
  mainWindow.show();
552
785
  }
553
786
  });
787
+ }).catch((err) => {
788
+ // Route explicit rejection through the safety-net so the user gets a
789
+ // GAIA-branded dialog and a stack trace in the log (issue #934).
790
+ _fatalHandler(err);
554
791
  });
555
792
 
556
793
  // ── Window-all-closed (C4 fix) ────────────────────────────────────────────
557
794
  // Don't quit when window is hidden — tray keeps app alive
558
795
  app.on("window-all-closed", () => {
796
+ // During bootstrap the progress dialog is the only open window. Destroying
797
+ // it (progress.close()) fires this event before the main window exists, which
798
+ // would trigger a premature app.quit() that races with the startup sequence
799
+ // and causes loadURL() to fail with ERR_FAILED (-2) — issue #934.
800
+ if (isBootstrapping) return;
801
+
559
802
  // If minimize-to-tray is active, the window is just hidden, not closed.
560
803
  // Only quit on macOS if the user explicitly quit (Cmd+Q).
561
804
  const trayActive = trayManager && trayManager.minimizeToTray;
@@ -575,6 +818,9 @@ let cleanupDone = false;
575
818
 
576
819
  app.on("before-quit", () => {
577
820
  isQuitting = true;
821
+ // Mark any subsequent backend exit as intentional so we don't pop the
822
+ // crash dialog during normal shutdown.
823
+ isIntentionalKill = true;
578
824
  });
579
825
 
580
826
  app.on("will-quit", (event) => {
@@ -628,39 +874,34 @@ async function cleanup() {
628
874
  trayManager = null;
629
875
  }
630
876
 
631
- // Stop the Python backend
877
+ // Stop the Python backend via the port-manager (SIGTERM → wait 3 s → SIGKILL).
878
+ // F5: previously we did this inline; extracted so main.cjs stays lean and
879
+ // to prevent orphan leaks across runs (issue #782).
632
880
  if (backendProcess) {
633
881
  console.log("Stopping backend process...");
634
- const proc = backendProcess; // Save reference before nulling
882
+ const proc = backendProcess;
635
883
  backendProcess = null;
884
+ isIntentionalKill = true;
636
885
 
637
886
  try {
638
- proc.kill("SIGTERM");
639
- } catch {
640
- // Already dead
887
+ // Pass the ChildProcess handle (not a bare pid) so port-manager
888
+ // can short-circuit via `proc.exitCode` and close the PID-reuse
889
+ // TOCTOU window.
890
+ await portManager.killBackend(proc);
891
+ } catch (err) {
892
+ console.error("Error stopping backend:", err.message);
641
893
  }
642
894
 
643
- // Wait for the process to exit, with a force-kill fallback
644
- await new Promise((resolve) => {
645
- // Check if already exited (exitCode is set once the process exits)
646
- if (proc.exitCode !== null) {
647
- resolve();
648
- return;
649
- }
650
-
651
- const forceKillTimer = setTimeout(() => {
652
- try {
653
- proc.kill("SIGKILL");
654
- } catch {
655
- // Already dead
656
- }
657
- resolve();
658
- }, 3000);
659
-
660
- proc.once("exit", () => {
661
- clearTimeout(forceKillTimer);
662
- resolve();
895
+ // If the child object still reports alive (race), give its exit
896
+ // listener a brief moment to fire before we continue teardown.
897
+ if (proc.exitCode === null) {
898
+ await new Promise((resolve) => {
899
+ const timer = setTimeout(resolve, 500);
900
+ proc.once("exit", () => {
901
+ clearTimeout(timer);
902
+ resolve();
903
+ });
663
904
  });
664
- });
905
+ }
665
906
  }
666
907
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.17.3",
3
+ "version": "0.17.5",
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",
@@ -35,6 +35,7 @@
35
35
  "bin/",
36
36
  "dist/",
37
37
  "main.cjs",
38
+ "main-safety-net.cjs",
38
39
  "preload.cjs",
39
40
  "services/",
40
41
  "assets/",