aegis-desktop 1.0.12 → 1.0.13
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/lib/google-connect.js +171 -0
- package/lib/local/engine.js +64 -1
- package/lib/local/prompt.js +67 -37
- package/lib/local/tools.js +49 -13
- package/main.js +191 -12
- package/package.json +2 -1
- package/preload.js +14 -0
- package/renderer/app.js +15 -5
- package/renderer/fingerprint.js +75 -1
- package/renderer/index.html +17 -0
- package/vendor/aegis.js +169 -3
- package/vendor/drive-connect.js +87 -0
- package/vendor/drive-upload.js +176 -0
- package/vendor/update.js +100 -8
package/vendor/aegis.js
CHANGED
|
@@ -466,6 +466,29 @@ const RATE_LIMIT_MAX_WAIT_MS = 30000;
|
|
|
466
466
|
/** Base for the exponential fallback when the server sends no `Retry-After`. */
|
|
467
467
|
const RATE_LIMIT_BASE_WAIT_MS = 1000;
|
|
468
468
|
|
|
469
|
+
/**
|
|
470
|
+
* aegis1's chat-completions generator (`app.py` `nexus_chat_completions`'s
|
|
471
|
+
* `generate()`) wraps the whole upstream provider call in one `except
|
|
472
|
+
* Exception`, and on ANY failure forwards `str(e)` as a mid-stream SSE error
|
|
473
|
+
* frame: `{"error": {"message": str(e)[:200], "type": "server_error"}}`.
|
|
474
|
+
* Headers are already sent 200 OK by then, so there is no status code to
|
|
475
|
+
* tell a reset upstream socket apart from a real bug — both arrive as
|
|
476
|
+
* `postStream` throwing with `status: 200` and the CLI/desktop host showing
|
|
477
|
+
* `(backend error: [Errno 104] Connection reset by peer)` mid-turn.
|
|
478
|
+
*
|
|
479
|
+
* Narrowed to the Python exception messages a dropped TCP connection
|
|
480
|
+
* actually produces (ConnectionResetError/BrokenPipeError/ConnectionAbortedError
|
|
481
|
+
* and their usual Errno numbers), not every empty-turn error — a model that
|
|
482
|
+
* legitimately returned nothing must still fail on the first try, not retry
|
|
483
|
+
* forever. Only safe to retry when nothing streamed yet (`fullText` empty at
|
|
484
|
+
* the throw site below): no visible content can be duplicated by redoing the
|
|
485
|
+
* whole request from scratch.
|
|
486
|
+
*/
|
|
487
|
+
const TRANSIENT_STREAM_ERROR_PATTERN =
|
|
488
|
+
/connection reset by peer|broken pipe|connection aborted|errno 104|errno 32|errno 54|econnreset|epipe/i;
|
|
489
|
+
const TRANSIENT_STREAM_RETRIES = 2;
|
|
490
|
+
const TRANSIENT_STREAM_BASE_WAIT_MS = 500;
|
|
491
|
+
|
|
469
492
|
/**
|
|
470
493
|
* .mcp.json / Electron pass config as "${VAR}" template refs. When the var is
|
|
471
494
|
* unset, hosts have been observed to leave the template literally unexpanded
|
|
@@ -505,6 +528,78 @@ function createClient(opts = {}) {
|
|
|
505
528
|
return value.split(apiKey).join('[redacted]');
|
|
506
529
|
}
|
|
507
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Transport failure codes → what actually happened, in words that point at
|
|
533
|
+
* the culprit. Names, not errno numbers, because "ECONNRESET" tells a user
|
|
534
|
+
* nothing while "the connection was reset mid-request" tells them to retry.
|
|
535
|
+
*/
|
|
536
|
+
const AEGIS_NETWORK_ERROR_HINTS = {
|
|
537
|
+
ECONNRESET: 'connection reset by the backend',
|
|
538
|
+
ECONNREFUSED: 'connection refused — is the backend running?',
|
|
539
|
+
EPIPE: 'connection closed while sending the request',
|
|
540
|
+
ETIMEDOUT: 'connection timed out',
|
|
541
|
+
ENOTFOUND: 'host not found — check the endpoint URL',
|
|
542
|
+
EAI_AGAIN: 'DNS lookup failed temporarily',
|
|
543
|
+
EHOSTUNREACH: 'host unreachable',
|
|
544
|
+
ENETUNREACH: 'network unreachable',
|
|
545
|
+
CERT_HAS_EXPIRED: 'TLS certificate has expired',
|
|
546
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS certificate is self-signed',
|
|
547
|
+
UND_ERR_SOCKET: 'the socket closed before the response finished',
|
|
548
|
+
UND_ERR_CLOSED: 'the connection was closed',
|
|
549
|
+
UND_ERR_CONNECT_TIMEOUT: 'timed out connecting to the backend',
|
|
550
|
+
UND_ERR_HEADERS_TIMEOUT: 'the backend accepted the connection but never sent a response',
|
|
551
|
+
UND_ERR_BODY_TIMEOUT: 'the backend stalled mid-response',
|
|
552
|
+
UND_ERR_ABORTED: 'the request was aborted',
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Turn a raw transport failure into something a person can act on.
|
|
557
|
+
*
|
|
558
|
+
* Node's undici raises `TypeError: fetch failed` for every socket-level
|
|
559
|
+
* fault and puts the real reason on `err.cause` (`ECONNRESET`,
|
|
560
|
+
* `UND_ERR_SOCKET`, `HeadersTimeoutError`, `ENOTFOUND`, …). Rethrown
|
|
561
|
+
* untouched, the user sees the literal string `fetch failed`, which names
|
|
562
|
+
* no component, no cause and no next step — and, worse, hides the fact that
|
|
563
|
+
* the backend is the thing that dropped us rather than a bad request.
|
|
564
|
+
*
|
|
565
|
+
* The cause code, when there is one, is the single most useful fact here,
|
|
566
|
+
* so it is promoted into `message` and kept on `cause`/`networkCode` for
|
|
567
|
+
* callers that want to branch on it. Everything else about the error is
|
|
568
|
+
* left alone.
|
|
569
|
+
*/
|
|
570
|
+
function networkFailureError(err, base) {
|
|
571
|
+
const reraised = err instanceof Error ? err : new Error(String(err));
|
|
572
|
+
// The caller's own cancellation is not a failure and must stay untouched.
|
|
573
|
+
const name = reraised && reraised.name;
|
|
574
|
+
if (name === 'AbortError' || name === 'TimeoutError') return err;
|
|
575
|
+
const detail = describeNetworkCause(reraised);
|
|
576
|
+
const target = base || 'the backend';
|
|
577
|
+
const e = new Error(
|
|
578
|
+
detail
|
|
579
|
+
? `Could not reach ${target} (${detail}).`
|
|
580
|
+
: `Could not reach ${target}: ${reraised.message || 'network failure'}.`
|
|
581
|
+
);
|
|
582
|
+
e.status = 0;
|
|
583
|
+
e.code = 'network';
|
|
584
|
+
e.networkCode = detail || null;
|
|
585
|
+
e.data = null;
|
|
586
|
+
e.cause = reraised;
|
|
587
|
+
return e;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The innermost human-meaningful reason from an undici error chain. */
|
|
591
|
+
function describeNetworkCause(err, depth = 0) {
|
|
592
|
+
if (!err || depth > 4) return null;
|
|
593
|
+
const code = err.code || (err.cause && err.cause.code);
|
|
594
|
+
const inner = err.cause;
|
|
595
|
+
const innerDetail = inner ? describeNetworkCause(inner, depth + 1) : null;
|
|
596
|
+
if (AEGIS_NETWORK_ERROR_HINTS[code]) return AEGIS_NETWORK_ERROR_HINTS[code];
|
|
597
|
+
if (innerDetail) return innerDetail;
|
|
598
|
+
// An undici cause often carries only a message ("other side closed").
|
|
599
|
+
if (inner && inner.message && inner.message !== err.message) return inner.message;
|
|
600
|
+
return code || null;
|
|
601
|
+
}
|
|
602
|
+
|
|
508
603
|
/**
|
|
509
604
|
* A bounded `fetch`: caller signal where there is one, an aborting timeout
|
|
510
605
|
* where there is not. Returns the caller's abort untouched (so a host that
|
|
@@ -549,7 +644,7 @@ function createClient(opts = {}) {
|
|
|
549
644
|
e.data = null;
|
|
550
645
|
throw e;
|
|
551
646
|
}
|
|
552
|
-
throw err;
|
|
647
|
+
throw networkFailureError(err, apiBase);
|
|
553
648
|
} finally {
|
|
554
649
|
// The timeout is time-to-*headers* only, so it is cleared the moment the
|
|
555
650
|
// response resolves: a long generation must never be cut off by it.
|
|
@@ -652,6 +747,30 @@ function createClient(opts = {}) {
|
|
|
652
747
|
await sleep(wait, init && init.signal);
|
|
653
748
|
res = await boundedFetch(url, init);
|
|
654
749
|
}
|
|
750
|
+
// The NON-streaming twin of postStream's mid-stream transient retry below:
|
|
751
|
+
// aegis1's chat-completions route drains the same generate() generator
|
|
752
|
+
// itself when the caller sent `stream: false`, and on the SAME exception
|
|
753
|
+
// (a reset socket to the provider) returns it as a plain 503 —
|
|
754
|
+
// `{"error":{"message":"[Errno 104] Connection reset by peer","type":
|
|
755
|
+
// "server_error"}}` — not a 200 SSE frame, because no response has been
|
|
756
|
+
// sent yet on this branch. Peeked via a clone so a non-matching body (a
|
|
757
|
+
// real 503 — insufficient balance, "not configured" — is untouched for
|
|
758
|
+
// `parseResponse` to read and surface normally, unretried.
|
|
759
|
+
for (
|
|
760
|
+
let attempt = 0;
|
|
761
|
+
res && res.status >= 500 && res.status < 600 && attempt < TRANSIENT_STREAM_RETRIES;
|
|
762
|
+
attempt++
|
|
763
|
+
) {
|
|
764
|
+
let text = '';
|
|
765
|
+
try {
|
|
766
|
+
text = await readCappedText(res.clone());
|
|
767
|
+
} catch {
|
|
768
|
+
break; // body already disturbed or unclonable — surface res as-is
|
|
769
|
+
}
|
|
770
|
+
if (!TRANSIENT_STREAM_ERROR_PATTERN.test(text)) break;
|
|
771
|
+
await sleep(TRANSIENT_STREAM_BASE_WAIT_MS * Math.pow(2, attempt), init && init.signal);
|
|
772
|
+
res = await boundedFetch(url, init);
|
|
773
|
+
}
|
|
655
774
|
return res;
|
|
656
775
|
}
|
|
657
776
|
|
|
@@ -1066,7 +1185,14 @@ function createClient(opts = {}) {
|
|
|
1066
1185
|
else if (mode) body.mode = mode;
|
|
1067
1186
|
const headers = authHeaders(agentKey ? { 'X-AEGIS-Agent-Key': agentKey } : undefined);
|
|
1068
1187
|
if (!stream || typeof onStream !== 'function') {
|
|
1069
|
-
|
|
1188
|
+
// `signal` must be forwarded here. It was not, and the consequence was
|
|
1189
|
+
// silent: a caller that aborted a non-streaming turn saw the abort
|
|
1190
|
+
// ignored, the request ran on to its timeout, and the eventual error was
|
|
1191
|
+
// a timeout the user never asked for. postStream's own fallback at the
|
|
1192
|
+
// bottom of this function always forwarded it — only this branch did not.
|
|
1193
|
+
return decodeDsmlInResult(
|
|
1194
|
+
await apiPost('/api/v1/chat/completions', { ...body, stream: false }, headers, { signal })
|
|
1195
|
+
);
|
|
1070
1196
|
}
|
|
1071
1197
|
// An OpenAI-compatible SSE response carries no `usage` unless the caller
|
|
1072
1198
|
// asks for it (`stream_options.include_usage`). Without this the streamed
|
|
@@ -1237,7 +1363,10 @@ function createClient(opts = {}) {
|
|
|
1237
1363
|
* This keeps streaming purely additive for hosts that opt in.
|
|
1238
1364
|
*/
|
|
1239
1365
|
async function postStream(path, body, headers, onStream, signal, streamOpts) {
|
|
1240
|
-
const {
|
|
1366
|
+
const {
|
|
1367
|
+
onReasoning, idleTimeoutMs, retryWithoutStreamOptions,
|
|
1368
|
+
_transientStreamAttempt: transientStreamAttempt = 0,
|
|
1369
|
+
} = streamOpts || {};
|
|
1241
1370
|
const request = (payload) =>
|
|
1242
1371
|
// `boundedFetch` here bounds time-to-*headers* only: fetch resolves when
|
|
1243
1372
|
// the status line lands, and the timer is cleared in its `finally`, so a
|
|
@@ -1545,6 +1674,16 @@ function createClient(opts = {}) {
|
|
|
1545
1674
|
}
|
|
1546
1675
|
|
|
1547
1676
|
if (!fullText && sseError) {
|
|
1677
|
+
if (
|
|
1678
|
+
transientStreamAttempt < TRANSIENT_STREAM_RETRIES
|
|
1679
|
+
&& TRANSIENT_STREAM_ERROR_PATTERN.test(sseError)
|
|
1680
|
+
) {
|
|
1681
|
+
await sleep(TRANSIENT_STREAM_BASE_WAIT_MS * Math.pow(2, transientStreamAttempt), signal);
|
|
1682
|
+
return postStream(path, body, headers, onStream, signal, {
|
|
1683
|
+
...streamOpts,
|
|
1684
|
+
_transientStreamAttempt: transientStreamAttempt + 1,
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1548
1687
|
const err = new Error(sseError);
|
|
1549
1688
|
err.status = res.status;
|
|
1550
1689
|
throw err;
|
|
@@ -1763,6 +1902,32 @@ function createClient(opts = {}) {
|
|
|
1763
1902
|
});
|
|
1764
1903
|
}
|
|
1765
1904
|
|
|
1905
|
+
/**
|
|
1906
|
+
* aegis1's stored Google Drive connection for this account (`GET
|
|
1907
|
+
* /api/drive/token` — app.py's `get_drive_token`), authenticated with the
|
|
1908
|
+
* ordinary API key (the same header `authHeaders()` already sends, which
|
|
1909
|
+
* that route accepts alongside its session-cookie path). Resolves
|
|
1910
|
+
* `{connected, access_token, email}` — `connected: false` with no error
|
|
1911
|
+
* when this account has never run the browser's `/connect/drive` consent
|
|
1912
|
+
* screen (the route's 404: a real account, just nothing saved yet), since
|
|
1913
|
+
* "not yet connected" is the expected answer on every account until it
|
|
1914
|
+
* does. A 401 is a DIFFERENT fact — this key is not valid at all — so it is
|
|
1915
|
+
* left to throw rather than folded into the same "not connected" reading,
|
|
1916
|
+
* which would hide a real credential problem behind the normal first-run
|
|
1917
|
+
* state. The access token is refreshed server-side from the stored refresh
|
|
1918
|
+
* token on every call, so a caller never has to reason about expiry.
|
|
1919
|
+
*/
|
|
1920
|
+
async function driveToken() {
|
|
1921
|
+
try {
|
|
1922
|
+
return await apiGet('/api/drive/token');
|
|
1923
|
+
} catch (e) {
|
|
1924
|
+
if (e && e.status === 404) {
|
|
1925
|
+
return { connected: false, access_token: null, email: null };
|
|
1926
|
+
}
|
|
1927
|
+
throw e;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1766
1931
|
return {
|
|
1767
1932
|
apiBase,
|
|
1768
1933
|
get apiKey() {
|
|
@@ -1798,6 +1963,7 @@ function createClient(opts = {}) {
|
|
|
1798
1963
|
conversationSyncPush,
|
|
1799
1964
|
conversationSyncPull,
|
|
1800
1965
|
importConversation,
|
|
1966
|
+
driveToken,
|
|
1801
1967
|
randomUUID,
|
|
1802
1968
|
};
|
|
1803
1969
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
+
// AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
|
|
4
|
+
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
+
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* drive-connect.js — the desktop half of aegis1's Google Drive OAuth.
|
|
10
|
+
*
|
|
11
|
+
* aegis1 already runs the full web side of this: `/connect/drive` sends the
|
|
12
|
+
* browser through Google's consent screen with the `drive.file` scope, saves
|
|
13
|
+
* the resulting tokens on whichever account that Google identity resolves
|
|
14
|
+
* to, and lands on a static "connected" page. `/api/drive/token` then hands
|
|
15
|
+
* back a fresh (server-refreshed) access token for that account, authenticated
|
|
16
|
+
* with the ordinary AEGIS API key — see `client/aegis.js`'s `driveToken()`.
|
|
17
|
+
*
|
|
18
|
+
* Unlike `/auth/google` (see `client/google-signin.js`), `/connect/drive` has
|
|
19
|
+
* no desktop round-trip: it never redirects back to a local callback, so
|
|
20
|
+
* there is nothing here to listen for. This module does the other honest
|
|
21
|
+
* thing instead — open the browser to it, and POLL the status endpoint
|
|
22
|
+
* (this machine's own API key) until the account shows connected, or give up
|
|
23
|
+
* after a timeout. The caller provides the actual browser-opening (Electron's
|
|
24
|
+
* `shell.openExternal`); this module is Electron-free so it can be driven by
|
|
25
|
+
* a test with a stub `aegis` client and no network.
|
|
26
|
+
*
|
|
27
|
+
* ONE PRECONDITION THIS MODULE DOES NOT ENFORCE, BECAUSE IT CANNOT:
|
|
28
|
+
* `/connect/drive` resolves the account by Google identity (google_id, then
|
|
29
|
+
* email), not by whatever account this machine's API key already belongs to.
|
|
30
|
+
* So polling only ever reports connected when THIS machine is already signed
|
|
31
|
+
* in with that same Google identity — otherwise the browser attaches the
|
|
32
|
+
* Drive tokens to a different account than the one being polled, and the
|
|
33
|
+
* poll simply times out. `desktop/lib/google-connect.js` is what sequences
|
|
34
|
+
* "sign in with Google first" ahead of this.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** `${apiBase}/connect/drive` — the one URL this flow opens. */
|
|
38
|
+
function driveConnectUrl(apiBase) {
|
|
39
|
+
return `${String(apiBase || '').replace(/\/+$/, '')}/connect/drive`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One status check: is a Drive token on file for this account?
|
|
44
|
+
*
|
|
45
|
+
* @param {{driveToken: () => Promise<{connected:boolean, email?:string}>}} aegis
|
|
46
|
+
*/
|
|
47
|
+
async function driveStatus(aegis) {
|
|
48
|
+
const data = await aegis.driveToken();
|
|
49
|
+
return {
|
|
50
|
+
connected: Boolean(data && data.connected),
|
|
51
|
+
email: (data && data.email) || null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Poll `driveStatus` until it reports connected, or time out.
|
|
57
|
+
*
|
|
58
|
+
* The browser round-trip has no signal of its own to wait on (unlike
|
|
59
|
+
* `startGoogleSignIn`'s local callback server), so this is the only way the
|
|
60
|
+
* caller learns the user actually finished the consent screen — or gave up
|
|
61
|
+
* on it, which is why this is bounded rather than polled forever.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} aegis a client exposing `driveToken()`
|
|
64
|
+
* @param {{timeoutMs?:number, pollMs?:number, signal?:AbortSignal}} [opts]
|
|
65
|
+
*/
|
|
66
|
+
async function waitForDriveConnected(aegis, opts = {}) {
|
|
67
|
+
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 3 * 60 * 1000;
|
|
68
|
+
const pollMs = Number(opts.pollMs) > 0 ? Number(opts.pollMs) : 2000;
|
|
69
|
+
const signal = opts.signal || null;
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
|
|
72
|
+
for (;;) {
|
|
73
|
+
const status = await driveStatus(aegis);
|
|
74
|
+
if (status.connected) return status;
|
|
75
|
+
if (signal && signal.aborted) {
|
|
76
|
+
const e = new Error('cancelled');
|
|
77
|
+
e.name = 'AbortError';
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
if (Date.now() >= deadline) {
|
|
81
|
+
throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s waiting for Google Drive to connect`);
|
|
82
|
+
}
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { driveConnectUrl, driveStatus, waitForDriveConnected };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
+
// AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
|
|
4
|
+
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
+
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* drive-upload.js — a Drive v3 REST client, scoped to exactly one job:
|
|
10
|
+
* keep a single memory-backup file, in a single app folder, up to date.
|
|
11
|
+
*
|
|
12
|
+
* This talks to Google's API directly with the access token
|
|
13
|
+
* `client/drive-connect.js` obtains (via `/api/drive/token`) — it never goes
|
|
14
|
+
* through aegis1, and aegis1 never sees the memory content this uploads.
|
|
15
|
+
*
|
|
16
|
+
* Scoped to `drive.file` (the scope `/connect/drive` requests): that grant
|
|
17
|
+
* only ever sees files this app itself creates, which is why every write
|
|
18
|
+
* here goes through `findOrCreateFolder`/`findFileInFolder` rather than
|
|
19
|
+
* assuming some well-known id — there is no well-known id this scope could
|
|
20
|
+
* resolve, and a file this app did not create is invisible to it even by
|
|
21
|
+
* name. One stable file is overwritten in place on every sync rather than
|
|
22
|
+
* creating a new one each time, so a daily backup habit does not pile up a
|
|
23
|
+
* thousand same-named files with no way to tell them apart.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const DRIVE_FILES_URL = 'https://www.googleapis.com/drive/v3/files';
|
|
27
|
+
const DRIVE_UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3/files';
|
|
28
|
+
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
|
29
|
+
const BOUNDARY = 'aegiscode-drive-upload-boundary';
|
|
30
|
+
|
|
31
|
+
const DEFAULT_FOLDER = 'AEGIS Memory';
|
|
32
|
+
const DEFAULT_FILE = 'aegis-memory-backup.json';
|
|
33
|
+
|
|
34
|
+
function resolveFetch(fetchImpl) {
|
|
35
|
+
const f = fetchImpl || (typeof fetch === 'function' ? fetch : null);
|
|
36
|
+
if (!f) throw new Error('drive: this Node has no global fetch — upgrade to Node 22+');
|
|
37
|
+
return f;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Escape a value for Drive's `q=` string-literal quoting rules. */
|
|
41
|
+
function escapeQ(value) {
|
|
42
|
+
return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function driveRequest(fetchImpl, url, { method = 'GET', accessToken, body, headers } = {}) {
|
|
46
|
+
const f = resolveFetch(fetchImpl);
|
|
47
|
+
const res = await f(url, {
|
|
48
|
+
method,
|
|
49
|
+
headers: { Authorization: `Bearer ${accessToken}`, ...(headers || {}) },
|
|
50
|
+
body,
|
|
51
|
+
});
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
let data = null;
|
|
54
|
+
try {
|
|
55
|
+
data = text ? JSON.parse(text) : null;
|
|
56
|
+
} catch {
|
|
57
|
+
data = null;
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const rawMsg = (data && data.error && (data.error.message || data.error)) || text.slice(0, 200) || `HTTP ${res.status}`;
|
|
61
|
+
const err = new Error(`drive: ${method} ${url.split('?')[0]} failed — ${typeof rawMsg === 'string' ? rawMsg : JSON.stringify(rawMsg)}`);
|
|
62
|
+
err.status = res.status;
|
|
63
|
+
err.data = data;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
return data || {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The first file matching an exact query, or null. */
|
|
70
|
+
async function findOne(fetchImpl, accessToken, query) {
|
|
71
|
+
const url = `${DRIVE_FILES_URL}?q=${encodeURIComponent(query)}&fields=${encodeURIComponent('files(id,name)')}&pageSize=1`;
|
|
72
|
+
const data = await driveRequest(fetchImpl, url, { accessToken });
|
|
73
|
+
const files = Array.isArray(data.files) ? data.files : [];
|
|
74
|
+
return files[0] || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** This app's folder by name, creating it the first time. Returns its id. */
|
|
78
|
+
async function findOrCreateFolder(accessToken, name, { fetchImpl } = {}) {
|
|
79
|
+
const query = `mimeType='${FOLDER_MIME}' and name='${escapeQ(name)}' and trashed=false`;
|
|
80
|
+
const existing = await findOne(fetchImpl, accessToken, query);
|
|
81
|
+
if (existing) return existing.id;
|
|
82
|
+
const created = await driveRequest(fetchImpl, DRIVE_FILES_URL, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
accessToken,
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({ name, mimeType: FOLDER_MIME }),
|
|
87
|
+
});
|
|
88
|
+
return created.id;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One file by name inside a given folder, or null. */
|
|
92
|
+
async function findFileInFolder(accessToken, name, folderId, { fetchImpl } = {}) {
|
|
93
|
+
const query = `'${escapeQ(folderId)}' in parents and name='${escapeQ(name)}' and trashed=false`;
|
|
94
|
+
return findOne(fetchImpl, accessToken, query);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a new file: a multipart/related body (metadata part naming the
|
|
99
|
+
* parent folder, content part carrying the bytes) — the shape Drive's
|
|
100
|
+
* `uploadType=multipart` requires for a create, since a plain media upload
|
|
101
|
+
* has nowhere to say which folder the file belongs in.
|
|
102
|
+
*/
|
|
103
|
+
async function createFile(accessToken, { name, folderId, content, mimeType }, { fetchImpl } = {}) {
|
|
104
|
+
const metadata = JSON.stringify({ name, parents: [folderId] });
|
|
105
|
+
const body =
|
|
106
|
+
`--${BOUNDARY}\r\n` +
|
|
107
|
+
`Content-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n` +
|
|
108
|
+
`--${BOUNDARY}\r\n` +
|
|
109
|
+
`Content-Type: ${mimeType}\r\n\r\n${content}\r\n` +
|
|
110
|
+
`--${BOUNDARY}--`;
|
|
111
|
+
return driveRequest(
|
|
112
|
+
fetchImpl,
|
|
113
|
+
`${DRIVE_UPLOAD_URL}?uploadType=multipart&fields=${encodeURIComponent('id,webViewLink')}`,
|
|
114
|
+
{
|
|
115
|
+
method: 'POST',
|
|
116
|
+
accessToken,
|
|
117
|
+
headers: { 'Content-Type': `multipart/related; boundary=${BOUNDARY}` },
|
|
118
|
+
body,
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Overwrite an existing file's content in place. A plain media upload is
|
|
125
|
+
* enough here — the name and parent folder never change on a resync, so
|
|
126
|
+
* there is nothing a metadata part would need to say.
|
|
127
|
+
*/
|
|
128
|
+
async function updateFileContent(accessToken, fileId, content, mimeType, { fetchImpl } = {}) {
|
|
129
|
+
return driveRequest(
|
|
130
|
+
fetchImpl,
|
|
131
|
+
`${DRIVE_UPLOAD_URL}/${encodeURIComponent(fileId)}?uploadType=media&fields=${encodeURIComponent('id,webViewLink')}`,
|
|
132
|
+
{
|
|
133
|
+
method: 'PATCH',
|
|
134
|
+
accessToken,
|
|
135
|
+
headers: { 'Content-Type': mimeType },
|
|
136
|
+
body: content,
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Upload (create or overwrite) one memory backup file in the user's Drive.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} accessToken a live Drive access token (client/drive-connect.js / aegis.driveToken())
|
|
145
|
+
* @param {string} content the file's bytes — the caller serialises; this module never shapes the memory export itself
|
|
146
|
+
* @param {{folderName?:string, fileName?:string, mimeType?:string, fetchImpl?:Function}} [opts]
|
|
147
|
+
* @returns {Promise<{fileId:string, webViewLink:string|null, updated:boolean}>}
|
|
148
|
+
*/
|
|
149
|
+
async function uploadMemoryBackup(accessToken, content, opts = {}) {
|
|
150
|
+
const folderName = opts.folderName || DEFAULT_FOLDER;
|
|
151
|
+
const fileName = opts.fileName || DEFAULT_FILE;
|
|
152
|
+
const mimeType = opts.mimeType || 'application/json';
|
|
153
|
+
const fetchImpl = opts.fetchImpl;
|
|
154
|
+
|
|
155
|
+
const folderId = await findOrCreateFolder(accessToken, folderName, { fetchImpl });
|
|
156
|
+
const existing = await findFileInFolder(accessToken, fileName, folderId, { fetchImpl });
|
|
157
|
+
const result = existing
|
|
158
|
+
? await updateFileContent(accessToken, existing.id, content, mimeType, { fetchImpl })
|
|
159
|
+
: await createFile(accessToken, { name: fileName, folderId, content, mimeType }, { fetchImpl });
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
fileId: result.id || (existing && existing.id) || null,
|
|
163
|
+
webViewLink: result.webViewLink || null,
|
|
164
|
+
updated: Boolean(existing),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
DEFAULT_FOLDER,
|
|
170
|
+
DEFAULT_FILE,
|
|
171
|
+
findOrCreateFolder,
|
|
172
|
+
findFileInFolder,
|
|
173
|
+
createFile,
|
|
174
|
+
updateFileContent,
|
|
175
|
+
uploadMemoryBackup,
|
|
176
|
+
};
|
package/vendor/update.js
CHANGED
|
@@ -399,19 +399,21 @@ function updateNotice({
|
|
|
399
399
|
*
|
|
400
400
|
* @param {{current: string, latest: ?string, behind: boolean, channel?: 'npm'|'desktop', pkg?: string}} input
|
|
401
401
|
*/
|
|
402
|
-
function updatePlan({ current, latest, behind, channel = 'npm', pkg = PKG } = {}) {
|
|
402
|
+
function updatePlan({ current, latest, behind, channel = 'npm', pkg = PKG, method = 'npm' } = {}) {
|
|
403
403
|
const currentText = String(current == null ? '' : current);
|
|
404
404
|
const latestText = latest ? String(latest) : null;
|
|
405
405
|
const pending = Boolean(behind) && Boolean(latestText);
|
|
406
406
|
if (!pending) {
|
|
407
407
|
return {
|
|
408
408
|
channel,
|
|
409
|
+
method,
|
|
409
410
|
current: currentText,
|
|
410
411
|
latest: latestText,
|
|
411
412
|
behind: false,
|
|
412
413
|
action: 'none',
|
|
413
414
|
applier: 'none',
|
|
414
415
|
instruction: null,
|
|
416
|
+
apply: null,
|
|
415
417
|
};
|
|
416
418
|
}
|
|
417
419
|
if (channel === 'desktop') {
|
|
@@ -420,25 +422,101 @@ function updatePlan({ current, latest, behind, channel = 'npm', pkg = PKG } = {}
|
|
|
420
422
|
// beside the running app. The applier is the app's own updater.
|
|
421
423
|
return {
|
|
422
424
|
channel,
|
|
425
|
+
method,
|
|
423
426
|
current: currentText,
|
|
424
427
|
latest: latestText,
|
|
425
428
|
behind: true,
|
|
426
429
|
action: 'desktop-update',
|
|
427
430
|
applier: 'electron-updater',
|
|
428
431
|
instruction: 'Aegis Desktop → Check for Updates…',
|
|
432
|
+
apply: null,
|
|
429
433
|
};
|
|
430
434
|
}
|
|
435
|
+
|
|
436
|
+
// Which installer owns this copy. The command is not cosmetic: `npm i -g`
|
|
437
|
+
// against a pip-installed `aegiscode` writes a second copy beside the one the
|
|
438
|
+
// user actually runs, and the "upgrade" appears to do nothing. Same for an
|
|
439
|
+
// `npx` run, which has nothing installed to upgrade at all.
|
|
440
|
+
const applier = APPLIERS[method] || APPLIERS.npm;
|
|
431
441
|
return {
|
|
432
442
|
channel,
|
|
443
|
+
method,
|
|
433
444
|
current: currentText,
|
|
434
445
|
latest: latestText,
|
|
435
446
|
behind: true,
|
|
436
|
-
action:
|
|
437
|
-
applier:
|
|
438
|
-
instruction:
|
|
447
|
+
action: applier.action,
|
|
448
|
+
applier: applier.name,
|
|
449
|
+
instruction: applier.instruction(pkg),
|
|
450
|
+
apply: applier.apply ? applier.apply(pkg) : null,
|
|
439
451
|
};
|
|
440
452
|
}
|
|
441
453
|
|
|
454
|
+
/**
|
|
455
|
+
* The ways this product reaches a machine, and what "update it" means for each.
|
|
456
|
+
*
|
|
457
|
+
* `apply` names argv to run — it is what the window's Update button and the
|
|
458
|
+
* CLI's `/upgrade apply` execute. `null` means there is nothing to install:
|
|
459
|
+
* `npx` fetches `latest` on every launch by definition, and a source checkout
|
|
460
|
+
* belongs to its owner's git remote.
|
|
461
|
+
*/
|
|
462
|
+
const APPLIERS = Object.freeze({
|
|
463
|
+
npm: Object.freeze({
|
|
464
|
+
name: 'npm',
|
|
465
|
+
action: 'npm-update',
|
|
466
|
+
instruction: (pkg) => `npm i -g ${pkg}@latest`,
|
|
467
|
+
apply: (pkg) => ({ command: 'npm', args: ['i', '-g', `${pkg}@latest`] }),
|
|
468
|
+
}),
|
|
469
|
+
pip: Object.freeze({
|
|
470
|
+
name: 'pip',
|
|
471
|
+
action: 'pip-update',
|
|
472
|
+
// The published pip package is a launcher for the npm one, so this is
|
|
473
|
+
// genuinely a different command — not an npm call with a different word.
|
|
474
|
+
instruction: (pkg) => `pip install -U ${pkg}`,
|
|
475
|
+
apply: (pkg) => ({ command: process.platform === 'win32' ? 'python' : 'python3',
|
|
476
|
+
args: ['-m', 'pip', 'install', '-U', pkg] }),
|
|
477
|
+
}),
|
|
478
|
+
npx: Object.freeze({
|
|
479
|
+
name: 'none',
|
|
480
|
+
action: 'npx-update',
|
|
481
|
+
instruction: () => 'already fetched fresh on every run — restart to pick up the new version',
|
|
482
|
+
apply: null,
|
|
483
|
+
}),
|
|
484
|
+
source: Object.freeze({
|
|
485
|
+
name: 'git',
|
|
486
|
+
action: 'source-update',
|
|
487
|
+
instruction: () => 'git pull && npm ci (this copy is a source checkout)',
|
|
488
|
+
apply: null,
|
|
489
|
+
}),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Which installer owns the copy that is running — so the sentence names a
|
|
494
|
+
* command that actually changes THIS binary.
|
|
495
|
+
*
|
|
496
|
+
* Order matters. An explicit declaration wins (the pip launcher sets
|
|
497
|
+
* `AEGIS_INSTALL_METHOD=pip` before exec'ing the real CLI, which is the only
|
|
498
|
+
* way the npm package can know a Python install is driving it). Then npx, which
|
|
499
|
+
* is detectable from the argv it builds. Then a source checkout, then npm.
|
|
500
|
+
*
|
|
501
|
+
* Pure and injectable: `env`, `argv` and a file probe, no `process` reads, so a
|
|
502
|
+
* test can assert every branch without npm or pip being installed.
|
|
503
|
+
*/
|
|
504
|
+
function detectInstallMethod({
|
|
505
|
+
env = {}, argv = [], pkg = PKG, fileExists = () => false, entry = '',
|
|
506
|
+
} = {}) {
|
|
507
|
+
const declared = String((env && env.AEGIS_INSTALL_METHOD) || '').trim().toLowerCase();
|
|
508
|
+
if (declared && Object.prototype.hasOwnProperty.call(APPLIERS, declared)) return declared;
|
|
509
|
+
|
|
510
|
+
const joined = argv.map((a) => String(a)).join(' ');
|
|
511
|
+
if (/\bnpx\b/.test(joined) || /_npx[\\/]/.test(joined) || /_npx[\\/]/.test(entry)) return 'npx';
|
|
512
|
+
if (/[\\/]pipx[\\/]venvs[\\/]/.test(entry) || /[\\/]site-packages[\\/]/.test(entry)) return 'pip';
|
|
513
|
+
if (/[\\/]node_modules[\\/]/.test(entry)) return 'npm';
|
|
514
|
+
// A git checkout has the repo's own marker files; a global npm install does
|
|
515
|
+
// not (they are not in the package's `files` list).
|
|
516
|
+
if (fileExists('.git') && fileExists('client/update.js')) return 'source';
|
|
517
|
+
return 'npm';
|
|
518
|
+
}
|
|
519
|
+
|
|
442
520
|
/**
|
|
443
521
|
* The same once-a-day check as `updateNotice`, but *awaited* and detailed.
|
|
444
522
|
*
|
|
@@ -518,11 +596,23 @@ async function checkPublished({
|
|
|
518
596
|
* Rendered from `updatePlan`, not from a second copy of the sentence: the
|
|
519
597
|
* command the terminal tells the user to run and the command the window's
|
|
520
598
|
* banner offers are the same string produced by the same verdict.
|
|
599
|
+
*
|
|
600
|
+
* `method` is the install channel the caller detected (see
|
|
601
|
+
* `detectInstallMethod`). It is threaded through rather than assumed to be npm
|
|
602
|
+
* because the previous version returned `null` — silence — for every channel
|
|
603
|
+
* that was not npm, so a pip install and an npx run were never told a release
|
|
604
|
+
* existed. A host that knows how it was installed should say so.
|
|
521
605
|
*/
|
|
522
|
-
function updateLine({ current, latest, behind, pkg = PKG }) {
|
|
523
|
-
const plan = updatePlan({ current, latest, behind, channel: 'npm', pkg });
|
|
524
|
-
if (plan.
|
|
525
|
-
|
|
606
|
+
function updateLine({ current, latest, behind, pkg = PKG, method = 'npm' }) {
|
|
607
|
+
const plan = updatePlan({ current, latest, behind, channel: 'npm', pkg, method });
|
|
608
|
+
if (!plan.behind) return null;
|
|
609
|
+
const head = `Update available: ${plan.current} → ${plan.latest}`;
|
|
610
|
+
// `apply` is null for the two channels with nothing to install, whose
|
|
611
|
+
// instruction is already a sentence — prefixing those with "run:" would read
|
|
612
|
+
// as a command that does not exist.
|
|
613
|
+
return plan.apply
|
|
614
|
+
? `${head} run: ${plan.instruction}`
|
|
615
|
+
: `${head} ${plan.instruction}`;
|
|
526
616
|
}
|
|
527
617
|
|
|
528
618
|
/**
|
|
@@ -607,6 +697,8 @@ module.exports = {
|
|
|
607
697
|
checkPublished,
|
|
608
698
|
updatePlan,
|
|
609
699
|
updateLine,
|
|
700
|
+
detectInstallMethod,
|
|
701
|
+
APPLIERS,
|
|
610
702
|
PKG,
|
|
611
703
|
CHECK_INTERVAL_MS,
|
|
612
704
|
REASONS,
|