@amd-gaia/agent-ui 0.22.0 → 0.24.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.
@@ -4,9 +4,19 @@
4
4
  /**
5
5
  * auto-updater.cjs — GAIA Agent UI auto-update service.
6
6
  *
7
- * Wraps `electron-updater` for the GitHub Releases auto-update flow.
7
+ * Wraps `electron-updater` against a CONFIGURABLE generic feed (R2-primary —
8
+ * issue #1724). The forward-update channel URL is resolved at runtime from
9
+ * `GAIA_UPDATE_FEED_URL` or `feedUrl` in ~/.gaia/update-config.json, and the
10
+ * app fetches the mutable `latest*.yml` channel pointer from it. When no feed
11
+ * is configured the updater enters a loud `no-channel` state (no silent
12
+ * no-op). The actual R2 publish feed + mutable channel pointer is milestone-52
13
+ * work (#1719); until it lands, point the env var at any generic feed (a local
14
+ * mock works) to exercise the path.
15
+ *
8
16
  * Implements §4 Layer 3 + §7 Phase F of docs/plans/desktop-installer.mdx.
9
- * Issue #1336: adds in-app rollback to a specific previous release.
17
+ * Issue #1336: in-app rollback to a specific previous release still lists and
18
+ * installs from GitHub Releases (that path is independent of the forward feed
19
+ * and migrates to R2 with #1719).
10
20
  *
11
21
  * Behavior:
12
22
  * - First check 10 seconds after `init()` is called (typically from
@@ -51,6 +61,12 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // Subsequent checks every 4h
51
61
  const LOG_PATH = path.join(os.homedir(), ".gaia", "electron-updater.log");
52
62
  const UPDATE_CONFIG_PATH = path.join(os.homedir(), ".gaia", "update-config.json");
53
63
 
64
+ // Mutable channel pointer name. electron-updater's generic provider fetches
65
+ // `${feedUrl}/${DEFAULT_CHANNEL}.yml` (+ per-platform variants). The Hub Worker
66
+ // re-points this file each release while versioned artifacts stay immutable —
67
+ // that split is the A6/A7 resolution (#1724).
68
+ const DEFAULT_CHANNEL = "latest";
69
+
54
70
  // per_page=100 is the GitHub API max for a single page; we fetch broadly so
55
71
  // draft/prerelease/platform filtering still yields a full page of installable
56
72
  // releases before the display cap below. >100 releases would need pagination
@@ -72,6 +88,8 @@ const STATES = Object.freeze({
72
88
  DOWNLOADED: "downloaded",
73
89
  ERROR: "error",
74
90
  DISABLED: "disabled",
91
+ // No forward-update feed configured — loud, actionable, never a silent no-op.
92
+ NO_CHANNEL: "no-channel",
75
93
  });
76
94
 
77
95
  // ── Module state ─────────────────────────────────────────────────────────────
@@ -93,6 +111,8 @@ let scheduledTimeout = null;
93
111
  let initialCheckTimeout = null;
94
112
  let ipcHandlersRegistered = false;
95
113
  let initialized = false;
114
+ // The resolved forward-update feed URL (generic/R2), or null when unconfigured.
115
+ let forwardFeedUrl = null;
96
116
 
97
117
  // Lazy-loaded Electron references (populated inside init()).
98
118
  let electronApi = null; // { dialog, ipcMain, app }
@@ -147,13 +167,86 @@ function _loadPin() {
147
167
  */
148
168
  function _savePin(pinnedVersion) {
149
169
  fs.mkdirSync(path.dirname(UPDATE_CONFIG_PATH), { recursive: true });
170
+ // Merge into any existing config so we don't clobber a persisted feedUrl.
171
+ let existing = {};
172
+ try {
173
+ if (fs.existsSync(UPDATE_CONFIG_PATH)) {
174
+ existing = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8")) || {};
175
+ }
176
+ } catch {
177
+ existing = {};
178
+ }
150
179
  fs.writeFileSync(
151
180
  UPDATE_CONFIG_PATH,
152
- JSON.stringify({ pinnedVersion }, null, 2),
181
+ JSON.stringify({ ...existing, pinnedVersion }, null, 2),
153
182
  "utf8"
154
183
  );
155
184
  }
156
185
 
186
+ // ── Forward-update feed resolution (issue #1724) ────────────────────────────────
187
+
188
+ /**
189
+ * Resolve the generic forward-update feed URL, or null if none is configured.
190
+ * Precedence: GAIA_UPDATE_FEED_URL env > feedUrl in update-config.json.
191
+ */
192
+ function _resolveFeedUrl() {
193
+ const envUrl = process.env.GAIA_UPDATE_FEED_URL;
194
+ if (typeof envUrl === "string" && envUrl.trim()) return envUrl.trim();
195
+ try {
196
+ if (fs.existsSync(UPDATE_CONFIG_PATH)) {
197
+ const cfg = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8"));
198
+ if (cfg && typeof cfg.feedUrl === "string" && cfg.feedUrl.trim()) {
199
+ return cfg.feedUrl.trim();
200
+ }
201
+ }
202
+ } catch (err) {
203
+ log("warn", "Failed to read feedUrl from update-config.json:", err && err.message);
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Point electron-updater at the configured generic (R2) feed + mutable channel.
210
+ * When nothing is configured, enter the loud NO_CHANNEL state and set no feed —
211
+ * an unconfigured updater must never silently pretend it's up to date.
212
+ *
213
+ * @returns {boolean} true when a feed was applied, false when NO_CHANNEL.
214
+ */
215
+ function _applyForwardFeed() {
216
+ if (!autoUpdaterRef) return false;
217
+ const url = _resolveFeedUrl();
218
+ if (!url) {
219
+ forwardFeedUrl = null;
220
+ setState({
221
+ status: STATES.NO_CHANNEL,
222
+ error:
223
+ "No update channel configured — set GAIA_UPDATE_FEED_URL (or feedUrl " +
224
+ "in ~/.gaia/update-config.json) to your R2 channel base URL. " +
225
+ "Auto-update is paused until a feed is configured.",
226
+ });
227
+ log("warn", "No update feed configured — auto-update paused (NO_CHANNEL)");
228
+ return false;
229
+ }
230
+ try {
231
+ autoUpdaterRef.setFeedURL({
232
+ provider: "generic",
233
+ url,
234
+ channel: DEFAULT_CHANNEL,
235
+ });
236
+ forwardFeedUrl = url;
237
+ log("info", `Update feed set (generic): ${url} [channel=${DEFAULT_CHANNEL}]`);
238
+ return true;
239
+ } catch (err) {
240
+ forwardFeedUrl = null;
241
+ setState({
242
+ status: STATES.ERROR,
243
+ error: `Failed to set update feed URL: ${(err && err.message) || String(err)}`,
244
+ });
245
+ log("error", "Failed to set feed URL:", err && err.message);
246
+ return false;
247
+ }
248
+ }
249
+
157
250
  // ── State management ─────────────────────────────────────────────────────────
158
251
 
159
252
  function broadcastState() {
@@ -359,8 +452,10 @@ async function installVersion(tag) {
359
452
  /**
360
453
  * Clear the version pin and resume normal auto-updates.
361
454
  *
362
- * Restores the GitHub provider feed, sets autoDownload=true, and clears
363
- * allowDowngrade so the next check is the normal forward-only flow.
455
+ * Restores the configured forward (R2 generic) feed, sets autoDownload=true,
456
+ * and clears allowDowngrade so the next check is the normal forward-only flow.
457
+ * If no feed is configured, resume lands in the loud NO_CHANNEL state rather
458
+ * than silently reverting to a stale rollback feed.
364
459
  */
365
460
  function clearPin() {
366
461
  // A failed clear is non-fatal — worst case the stale pin re-pauses updates
@@ -375,17 +470,9 @@ function clearPin() {
375
470
  if (autoUpdaterRef) {
376
471
  autoUpdaterRef.autoDownload = true;
377
472
  autoUpdaterRef.allowDowngrade = false;
378
- // Restore the GitHub provider (overrides any generic/tagged feed set by
379
- // installVersion). Owner/repo must match electron-builder.yml publish block.
380
- try {
381
- autoUpdaterRef.setFeedURL({
382
- provider: "github",
383
- owner: "amd",
384
- repo: "gaia",
385
- });
386
- } catch (err) {
387
- log("warn", "Failed to restore GitHub feed on resume:", err && err.message);
388
- }
473
+ // Restore the forward channel (overrides any generic/tagged rollback feed
474
+ // set by installVersion). Loud NO_CHANNEL if unconfigured.
475
+ _applyForwardFeed();
389
476
  }
390
477
 
391
478
  log("info", "Version pin cleared — auto-updates resumed");
@@ -403,6 +490,16 @@ async function checkForUpdates() {
403
490
  log("warn", "checkForUpdates called before init — ignoring");
404
491
  return;
405
492
  }
493
+ // No forward feed and not pinned → surface the loud NO_CHANNEL state instead
494
+ // of asking electron-updater to check a feed that doesn't exist. Re-resolve
495
+ // first so a feed written to config after init() is picked up here.
496
+ if (!forwardFeedUrl && !state.pinnedVersion) {
497
+ if (!_applyForwardFeed()) return;
498
+ // Feed just became available after a no-feed (NO_CHANNEL) start — init()
499
+ // skipped arming the periodic scheduler, so arm it now or background checks
500
+ // never resume once the feed goes live.
501
+ if (!scheduledTimeout) scheduleNextCheck();
502
+ }
406
503
  if (checkInProgress) {
407
504
  log("info", "Skipping check — another check is already in progress");
408
505
  return;
@@ -691,6 +788,25 @@ function init(mainWindow) {
691
788
  log("warn", "Failed to register IPC handlers:", err && err.message);
692
789
  }
693
790
 
791
+ // Point the updater at the configured R2 generic feed (#1724). When a pin is
792
+ // active, installVersion() owns the feed (rollback path), so skip. With no
793
+ // pin and no feed, _applyForwardFeed enters the loud NO_CHANNEL state and we
794
+ // don't schedule checks against a nonexistent feed.
795
+ let feedReady = true;
796
+ if (!savedPin) {
797
+ feedReady = _applyForwardFeed();
798
+ }
799
+
800
+ initialized = true;
801
+
802
+ if (!feedReady && !savedPin) {
803
+ log(
804
+ "info",
805
+ "Auto-updater initialized without a channel — checks paused until a feed is configured"
806
+ );
807
+ return;
808
+ }
809
+
694
810
  // First check after CHECK_DELAY_MS, then every CHECK_INTERVAL_MS.
695
811
  initialCheckTimeout = setTimeout(async () => {
696
812
  try {
@@ -701,7 +817,6 @@ function init(mainWindow) {
701
817
  scheduleNextCheck();
702
818
  }, CHECK_DELAY_MS);
703
819
 
704
- initialized = true;
705
820
  log(
706
821
  "info",
707
822
  `Auto-updater initialized; first check in ${CHECK_DELAY_MS}ms`
@@ -399,7 +399,7 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
399
399
  return "retry";
400
400
  case 2: {
401
401
  try {
402
- await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
402
+ await shell.openExternal("https://amd-gaia.ai/docs/quickstart#cli-install");
403
403
  } catch {
404
404
  // ignore
405
405
  }
@@ -645,7 +645,8 @@ function _extraCaCertificates() {
645
645
  try {
646
646
  return [fs.readFileSync(file, "utf8")];
647
647
  } catch (err) {
648
- log(`Could not read NODE_EXTRA_CA_CERTS (${file}): ${err.message}`);
648
+ // err.message already names the offending path — don't re-log the env value.
649
+ log(`Could not read NODE_EXTRA_CA_CERTS: ${err.message}`);
649
650
  return [];
650
651
  }
651
652
  }
@@ -725,7 +726,10 @@ function _probeViaProxy(target, proxy, ca, finish, setSock) {
725
726
  try {
726
727
  proxyUrl = new URL(proxy);
727
728
  } catch {
728
- finish({ ok: false, kind: "connectivity", message: `${target.href}: invalid proxy URL "${proxy}"` });
729
+ // HTTPS_PROXY/HTTP_PROXY may embed credentials (http://user:pass@host) —
730
+ // strip the userinfo before the value reaches the install log.
731
+ const redacted = proxy.replace(/^(\w+:\/\/)[^@/]*@/, "$1***@");
732
+ finish({ ok: false, kind: "connectivity", message: `${target.href}: invalid proxy URL "${redacted}"` });
729
733
  return;
730
734
  }
731
735
  const headers = {};
@@ -1530,11 +1534,11 @@ async function installBackend(opts = {}) {
1530
1534
  ? "GAIA retried with the OS certificate store and it still failed. "
1531
1535
  : "") +
1532
1536
  "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";
1537
+ "See https://amd-gaia.ai/docs/quickstart#cli-install";
1534
1538
  } else {
1535
1539
  suggestion = `Try installing manually:\n uv pip install ${pipPackage} --python ${
1536
1540
  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`;
1541
+ }\nThen restart GAIA. See https://amd-gaia.ai/docs/quickstart#cli-install`;
1538
1542
  }
1539
1543
  throw new InstallError(
1540
1544
  `Failed to install ${pipPackage} (pip exit ${installResult.code}).`,
@@ -1551,7 +1555,7 @@ async function installBackend(opts = {}) {
1551
1555
  `GAIA binary not found at ${GAIA_VENV_DISPLAY} after install.`,
1552
1556
  {
1553
1557
  stage: STAGES.INSTALL_PACKAGE,
1554
- suggestion: "The package was installed but the gaia executable is missing. Try reinstalling from https://amd-gaia.ai/quickstart",
1558
+ suggestion: "The package was installed but the gaia executable is missing. Try reinstalling from https://amd-gaia.ai/docs/quickstart",
1555
1559
  }
1556
1560
  );
1557
1561
  }
@@ -1788,7 +1792,7 @@ async function ensureBackend(opts = {}) {
1788
1792
  "GAIA backend not found after installation.",
1789
1793
  {
1790
1794
  stage: STAGES.VERIFY,
1791
- suggestion: "Check the log file and try reinstalling. See https://amd-gaia.ai/quickstart",
1795
+ suggestion: "Check the log file and try reinstalling. See https://amd-gaia.ai/docs/quickstart",
1792
1796
  }
1793
1797
  );
1794
1798
  setState(STATES.FAILED, {
@@ -0,0 +1,230 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * deep-link.cjs — parse `gaia://` deep links (issue #1725).
6
+ *
7
+ * The website's "Open in GAIA" button hands the running app a
8
+ * `gaia://hub/install/<id>` URL. This module is the pure, Electron-free
9
+ * parser so it can be unit-tested without a running app; main.cjs owns the
10
+ * OS protocol registration and dispatch to the install runtime.
11
+ *
12
+ * Design: parse errors are THROWN with an actionable message (no silent
13
+ * fallback). main.cjs surfaces the message to the user instead of dropping a
14
+ * malformed link on the floor.
15
+ */
16
+
17
+ "use strict";
18
+
19
+ const SCHEME = "gaia:";
20
+
21
+ // Agent ids as published on the hub: start alphanumeric, then letters, digits,
22
+ // dot, dash, underscore. Bounded length keeps a hostile link from ballooning.
23
+ const AGENT_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
24
+
25
+ /**
26
+ * Parse a `gaia://` deep link into a structured command.
27
+ *
28
+ * Supported form:
29
+ * gaia://hub/install/<id> → { action: "install", agentId }
30
+ *
31
+ * @param {string} rawUrl
32
+ * @returns {{ action: "install", agentId: string }}
33
+ * @throws {Error} with an actionable message on anything malformed/unsupported
34
+ */
35
+ function parseDeepLink(rawUrl) {
36
+ if (typeof rawUrl !== "string" || !rawUrl.trim()) {
37
+ throw new Error(
38
+ 'Not a GAIA deep link — expected a "gaia://…" URL but got ' +
39
+ (rawUrl === "" ? "an empty string" : JSON.stringify(rawUrl)) +
40
+ "."
41
+ );
42
+ }
43
+
44
+ const trimmed = rawUrl.trim();
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(trimmed);
48
+ } catch {
49
+ throw new Error(`Malformed GAIA deep link "${trimmed}" — not a valid URL.`);
50
+ }
51
+
52
+ if (parsed.protocol !== SCHEME) {
53
+ throw new Error(
54
+ `Unsupported URL scheme "${parsed.protocol}" — GAIA deep links must start with "gaia://".`
55
+ );
56
+ }
57
+
58
+ const host = parsed.hostname;
59
+ const segments = parsed.pathname.split("/").filter(Boolean);
60
+
61
+ if (host === "hub" && segments[0] === "install") {
62
+ const agentId = segments[1] ? decodeURIComponent(segments[1]) : "";
63
+ if (!agentId) {
64
+ throw new Error(
65
+ `GAIA install link "${trimmed}" is missing an agent id — expected gaia://hub/install/<id>.`
66
+ );
67
+ }
68
+ if (!AGENT_ID_RE.test(agentId)) {
69
+ throw new Error(
70
+ `GAIA install link has an invalid agent id "${agentId}" — ids may contain letters, digits, dot, dash and underscore.`
71
+ );
72
+ }
73
+ if (segments.length > 2) {
74
+ throw new Error(
75
+ `GAIA install link "${trimmed}" has unexpected extra path segments — expected gaia://hub/install/<id>.`
76
+ );
77
+ }
78
+ return { action: "install", agentId };
79
+ }
80
+
81
+ throw new Error(
82
+ `Unrecognized GAIA deep link "${trimmed}" — only gaia://hub/install/<id> is supported.`
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Find the first `gaia://` argument in an argv array (Windows/Linux hand the
88
+ * deep link to the app as a command-line argument). Returns null if absent.
89
+ *
90
+ * @param {string[]} argv
91
+ * @returns {string | null}
92
+ */
93
+ function extractDeepLinkFromArgv(argv) {
94
+ if (!Array.isArray(argv)) return null;
95
+ for (const arg of argv) {
96
+ if (typeof arg === "string" && arg.startsWith("gaia://")) return arg;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Act on a parsed deep-link command, with every Electron/runtime effect
103
+ * injected so the security-critical control flow is unit-testable.
104
+ *
105
+ * SECURITY (issue #2196 review): a `gaia://hub/install/<id>` link comes from an
106
+ * untrusted web page, so the install MUST be gated behind an explicit per-agent
107
+ * confirmation — the same trust bar the in-app install enforces (#2201). A bare
108
+ * OS "Open GAIA?" prompt is NOT consent to download and run a specific agent.
109
+ * If `confirm` does not return true, no install is attempted. `confirm` is
110
+ * expected to have shown the agent's real catalog tier and permissions first
111
+ * (see `buildInstallPrompt`), not just its bare id, before resolving true.
112
+ *
113
+ * Once confirmed, the install is sent with the trust override set — the
114
+ * backend's non-verified gate now covers every non-verified agent, not just
115
+ * native ones, and this out-of-band flow already required its own explicit
116
+ * per-agent confirmation regardless of tier.
117
+ *
118
+ * @param {{ action: string, agentId: string }} command
119
+ * @param {{
120
+ * confirm: (command: {action: string, agentId: string}) => Promise<boolean>,
121
+ * installAgent: (agentId: string, opts?: { trustNative?: boolean }) => Promise<any>,
122
+ * focusWindow?: () => void,
123
+ * logger?: { log?: (...a: any[]) => void, error?: (...a: any[]) => void },
124
+ * }} deps
125
+ * @returns {Promise<{ installed: boolean, reason?: string }>}
126
+ */
127
+ async function dispatchDeepLink(command, deps) {
128
+ const { confirm, installAgent, focusWindow, logger } = deps || {};
129
+ const log = (logger && logger.log) || (() => {});
130
+
131
+ if (!command || command.action !== "install") {
132
+ throw new Error(
133
+ `Unsupported deep-link action "${command && command.action}".`
134
+ );
135
+ }
136
+ if (typeof confirm !== "function" || typeof installAgent !== "function") {
137
+ throw new Error(
138
+ "dispatchDeepLink requires confirm() and installAgent() dependencies"
139
+ );
140
+ }
141
+
142
+ // Surface the app first so the confirmation dialog is clearly GAIA's.
143
+ if (typeof focusWindow === "function") focusWindow();
144
+
145
+ const approved = await confirm(command);
146
+ if (approved !== true) {
147
+ log(`[deep-link] User declined install of "${command.agentId}"`);
148
+ return { installed: false, reason: "declined" };
149
+ }
150
+
151
+ log(`[deep-link] Confirmed — installing "${command.agentId}"`);
152
+ await installAgent(command.agentId, { trustNative: true });
153
+ return { installed: true };
154
+ }
155
+
156
+ // ── Informed trust prompt (security fix: gate ALL non-verified agents) ─────
157
+ //
158
+ // The bare confirm dialog above only ever showed the raw agent id — no tier,
159
+ // no permissions, no AMD-verified indicator. That's per-agent confirmation,
160
+ // but it isn't INFORMED consent. buildInstallPrompt renders the same facts
161
+ // the in-app trust gate (hubLanes.trustGateFor) shows, from a catalog entry,
162
+ // so the caller (main.cjs) can put them in front of the user before they
163
+ // confirm.
164
+
165
+ const TIER_LABEL = {
166
+ verified: "AMD Verified",
167
+ community: "Community",
168
+ experimental: "Experimental",
169
+ };
170
+
171
+ /** Normalize a possibly-missing/unknown tier the same way the in-app gate does. */
172
+ function normalizeTier(entry) {
173
+ const t = entry && entry.security_tier;
174
+ return t === "verified" || t === "community" ? t : "experimental";
175
+ }
176
+
177
+ /**
178
+ * Build the confirmation prompt for a deep-link install from its catalog
179
+ * entry. Pure — no Electron dependency — so the trust posture is
180
+ * unit-testable without a running dialog.
181
+ *
182
+ * @param {{
183
+ * id: string,
184
+ * name?: string,
185
+ * security_tier?: "verified" | "community" | "experimental",
186
+ * permissions?: string[],
187
+ * } | null | undefined} entry — the agent's catalog entry.
188
+ * @returns {{ title: string, message: string, detail: string, requiresTrust: boolean }}
189
+ * @throws {Error} if no entry is given — never build a prompt for an agent
190
+ * that couldn't be looked up; the caller must fail closed instead.
191
+ */
192
+ function buildInstallPrompt(entry) {
193
+ if (!entry || typeof entry !== "object") {
194
+ throw new Error(
195
+ "buildInstallPrompt requires a catalog entry — refuse rather than prompting blind."
196
+ );
197
+ }
198
+
199
+ const tier = normalizeTier(entry);
200
+ const requiresTrust = tier !== "verified";
201
+ const name = entry.name || entry.id;
202
+ const permissions = Array.isArray(entry.permissions) ? entry.permissions : [];
203
+
204
+ const detail = [
205
+ `Tier: ${TIER_LABEL[tier]}${requiresTrust ? " — NOT AMD-verified" : ""}`,
206
+ permissions.length
207
+ ? `Permissions: ${permissions.join(", ")}`
208
+ : "Permissions: none declared",
209
+ requiresTrust
210
+ ? "This agent has not been audited by AMD. Only continue if you trust the " +
211
+ "publisher — installing downloads and runs third-party code on your machine."
212
+ : "This agent has been reviewed and verified by AMD.",
213
+ ].join("\n\n");
214
+
215
+ return {
216
+ title: requiresTrust
217
+ ? "Install untrusted agent from the web?"
218
+ : "Install agent from the web?",
219
+ message: `A website asked GAIA to install "${name}" (${entry.id}).`,
220
+ detail,
221
+ requiresTrust,
222
+ };
223
+ }
224
+
225
+ module.exports = {
226
+ parseDeepLink,
227
+ extractDeepLinkFromArgv,
228
+ dispatchDeepLink,
229
+ buildInstallPrompt,
230
+ };
@@ -0,0 +1,154 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * GAIA Agent UI — System metrics collection (issue #2007)
6
+ *
7
+ * Collects real host metrics (CPU, memory, disk, network, per-process
8
+ * usage) for the observability dashboard and exposes them to the
9
+ * renderer over the `system:get-metrics` IPC channel. The shape matches
10
+ * the renderer's `SystemMetrics` type (src/types/agent.ts).
11
+ *
12
+ * GPU/NPU utilization is intentionally omitted (left `undefined`): there
13
+ * is no dependency-free cross-platform source for it, and the renderer
14
+ * type marks those fields optional — `undefined` renders as
15
+ * "unavailable", never as a fake zero.
16
+ *
17
+ * Errors propagate: a failing source rejects the IPC invoke so the
18
+ * renderer can surface it. No silent nulls.
19
+ */
20
+
21
+ "use strict";
22
+
23
+ const fs = require("fs");
24
+ const os = require("os");
25
+ const { app, ipcMain } = require("electron");
26
+
27
+ const SYSTEM_GET_METRICS_CHANNEL = "system:get-metrics";
28
+
29
+ const BYTES_PER_GB = 1024 * 1024 * 1024;
30
+
31
+ /** Minimum sampling window for the very first CPU reading (ms). */
32
+ const FIRST_CPU_SAMPLE_MS = 120;
33
+
34
+ // Previous aggregate CPU times — CPU% is a delta between two samples.
35
+ let prevCpuTimes = null;
36
+
37
+ function readCpuTimes() {
38
+ let idle = 0;
39
+ let total = 0;
40
+ for (const cpu of os.cpus()) {
41
+ for (const [kind, ms] of Object.entries(cpu.times)) {
42
+ total += ms;
43
+ if (kind === "idle") idle += ms;
44
+ }
45
+ }
46
+ return { idle, total };
47
+ }
48
+
49
+ function cpuPercentBetween(prev, curr) {
50
+ const totalDelta = curr.total - prev.total;
51
+ const idleDelta = curr.idle - prev.idle;
52
+ if (totalDelta <= 0) return 0;
53
+ const pct = (1 - idleDelta / totalDelta) * 100;
54
+ return Math.min(100, Math.max(0, pct));
55
+ }
56
+
57
+ async function sampleCpuPercent() {
58
+ if (prevCpuTimes === null) {
59
+ // First call: take a short two-point sample so the reading is real,
60
+ // not a since-boot average.
61
+ prevCpuTimes = readCpuTimes();
62
+ await new Promise((r) => setTimeout(r, FIRST_CPU_SAMPLE_MS));
63
+ }
64
+ const curr = readCpuTimes();
65
+ const pct = cpuPercentBetween(prevCpuTimes, curr);
66
+ prevCpuTimes = curr;
67
+ return pct;
68
+ }
69
+
70
+ async function readDiskUsage(diskPath) {
71
+ let stat;
72
+ try {
73
+ stat = await fs.promises.statfs(diskPath);
74
+ } catch (err) {
75
+ throw new Error(
76
+ `Failed to read disk usage for ${diskPath}: ${err.message}. ` +
77
+ "The observability dashboard needs a readable filesystem path — " +
78
+ "check that the path exists and is accessible."
79
+ );
80
+ }
81
+ const totalBytes = stat.blocks * stat.bsize;
82
+ const freeBytes = stat.bfree * stat.bsize;
83
+ return {
84
+ diskUsedGB: (totalBytes - freeBytes) / BYTES_PER_GB,
85
+ diskTotalGB: totalBytes / BYTES_PER_GB,
86
+ };
87
+ }
88
+
89
+ function readProcesses(now) {
90
+ // Electron's own process tree (main, renderer, GPU, utility) — the
91
+ // processes this app can meaningfully report on without a system-wide
92
+ // process scanner dependency.
93
+ return app.getAppMetrics().map((p) => ({
94
+ pid: p.pid,
95
+ name: p.name || p.serviceName || p.type,
96
+ cpuPercent: p.cpu.percentCPUUsage,
97
+ memoryMB: p.memory.workingSetSize / 1024, // workingSetSize is in KB
98
+ uptime: p.creationTime ? Math.max(0, (now - p.creationTime) / 1000) : 0,
99
+ }));
100
+ }
101
+
102
+ function readNetworkUp() {
103
+ return Object.values(os.networkInterfaces()).some((addrs) =>
104
+ (addrs || []).some((a) => !a.internal)
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Collect a full SystemMetrics snapshot from the real host.
110
+ *
111
+ * @param {object} [options]
112
+ * @param {string} [options.diskPath] Filesystem path to report disk usage
113
+ * for (defaults to the user's home directory).
114
+ * @returns {Promise<object>} SystemMetrics-shaped snapshot.
115
+ */
116
+ async function collectSystemMetrics(options = {}) {
117
+ const diskPath = options.diskPath || os.homedir();
118
+
119
+ const [cpuPercent, disk] = await Promise.all([
120
+ sampleCpuPercent(),
121
+ readDiskUsage(diskPath),
122
+ ]);
123
+
124
+ const totalMem = os.totalmem();
125
+ const freeMem = os.freemem();
126
+ const now = Date.now();
127
+
128
+ return {
129
+ cpuPercent,
130
+ memoryUsedGB: (totalMem - freeMem) / BYTES_PER_GB,
131
+ memoryTotalGB: totalMem / BYTES_PER_GB,
132
+ diskUsedGB: disk.diskUsedGB,
133
+ diskTotalGB: disk.diskTotalGB,
134
+ networkUp: readNetworkUp(),
135
+ processes: readProcesses(now),
136
+ timestamp: now,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Register the `system:get-metrics` IPC handler. Collection errors reject
142
+ * the renderer's invoke() so the store can surface them.
143
+ */
144
+ function registerIpcHandlers() {
145
+ ipcMain.handle(SYSTEM_GET_METRICS_CHANNEL, async () => {
146
+ return collectSystemMetrics();
147
+ });
148
+ }
149
+
150
+ module.exports = {
151
+ collectSystemMetrics,
152
+ registerIpcHandlers,
153
+ SYSTEM_GET_METRICS_CHANNEL,
154
+ };