@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.
- package/bin/gaia-ui.cjs +1 -1
- package/dist/assets/{browser-CmSowSA-.js → browser-D6fty1nl.js} +3 -3
- package/dist/assets/index-C0wmkHcD.js +69 -0
- package/dist/assets/index-D1S2dWYe.css +1 -0
- package/dist/index.html +2 -2
- package/main.cjs +198 -2
- package/package.json +6 -6
- package/preload.cjs +10 -1
- package/services/agent-process-manager.cjs +287 -6
- package/services/auto-updater.cjs +132 -17
- package/services/backend-installer-progress-dialog.cjs +1 -1
- package/services/backend-installer.cjs +10 -6
- package/services/deep-link.cjs +230 -0
- package/services/system-metrics.cjs +154 -0
- package/dist/assets/index-B31DMoxr.css +0 -1
- package/dist/assets/index-lScW7mUY.js +0 -72
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* auto-updater.cjs — GAIA Agent UI auto-update service.
|
|
6
6
|
*
|
|
7
|
-
* Wraps `electron-updater`
|
|
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:
|
|
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
|
|
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
|
|
379
|
-
// installVersion).
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|