aegis-desktop 1.0.10 → 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/auto.js +11 -7
- package/lib/fingerprint.js +76 -17
- package/lib/google-connect.js +171 -0
- package/lib/local/claude-pro.js +134 -11
- package/lib/local/engine.js +111 -26
- package/lib/local/prompt.js +67 -37
- package/lib/local/tools.js +49 -13
- package/lib/settings.js +65 -24
- package/main.js +270 -36
- package/package.json +5 -2
- package/preload.js +45 -12
- package/renderer/app.js +691 -22
- package/renderer/budget.js +18 -9
- package/renderer/companion.js +225 -0
- package/renderer/fingerprint.css +16 -0
- package/renderer/fingerprint.js +136 -2
- package/renderer/index.html +302 -121
- package/renderer/provider-toolbar.js +136 -0
- package/renderer/providers.js +261 -0
- package/renderer/style.css +547 -171
- package/vendor/aegis.js +169 -3
- package/vendor/credentials.js +9 -2
- package/vendor/drive-connect.js +87 -0
- package/vendor/drive-upload.js +176 -0
- package/vendor/google-signin.js +127 -0
- package/vendor/record-vault.js +431 -113
- package/vendor/update.js +366 -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
|
}
|
package/vendor/credentials.js
CHANGED
|
@@ -382,8 +382,15 @@ function clientOptions(o = {}) {
|
|
|
382
382
|
const { key } = resolveApiKey(o);
|
|
383
383
|
const { token } = resolveMemoryToken(o);
|
|
384
384
|
const opts = {};
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
// Set explicitly, even when empty: `createClient` only re-checks its own
|
|
386
|
+
// (real, ambient) process.env when `apiKey`/`memoryToken` are `undefined`.
|
|
387
|
+
// Omitting an empty result here was indistinguishable from "not resolved
|
|
388
|
+
// yet" and let that fallback see the real environment's key even when an
|
|
389
|
+
// injected `o.env`/`o.dir` (tests, a hosted agent, a `--base` against a
|
|
390
|
+
// staging account — the reason this function takes them at all) had
|
|
391
|
+
// already and correctly resolved to "no key".
|
|
392
|
+
opts.apiKey = key;
|
|
393
|
+
opts.memoryToken = token;
|
|
387
394
|
// The host names itself so upgrade prompts raised inside the shared client
|
|
388
395
|
// are attributed to the right product. Unset means desktop, which matches
|
|
389
396
|
// createClient's own default.
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
* Sign this machine in with the user's actual Google identity, instead of the
|
|
10
|
+
* anonymous per-machine key `auto-provision.js`/`connect.js` mint by default.
|
|
11
|
+
*
|
|
12
|
+
* aegis1 already runs the full web side of this (`app.py`: `/auth/google`,
|
|
13
|
+
* `/auth/callback/google`) including a desktop round-trip it was built for:
|
|
14
|
+
* pass `?callback=http://127.0.0.1:{port}` and, once the browser finishes the
|
|
15
|
+
* Google consent screen, aegis1 redirects the browser back to that local
|
|
16
|
+
* address with `token`, `username` and (when available) `memory_token` as
|
|
17
|
+
* query params. Nothing here talks to Google or to aegis1 directly — this
|
|
18
|
+
* module only runs the localhost half of that round-trip: open a one-shot
|
|
19
|
+
* HTTP server, hand the caller the URL to open, and resolve once the browser
|
|
20
|
+
* comes back to it.
|
|
21
|
+
*
|
|
22
|
+
* `token` IS the ordinary AEGIS API key for the account aegis1 resolved
|
|
23
|
+
* (looked up or created by `google_id`/email) — there is no second credential
|
|
24
|
+
* format to support. The caller saves it exactly as `register-device`'s key,
|
|
25
|
+
* through `credentials.saveApiKey`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const http = require('node:http');
|
|
29
|
+
const { URL } = require('node:url');
|
|
30
|
+
|
|
31
|
+
/** Escapes text placed into the one-shot HTML page — the values riding in the
|
|
32
|
+
* query string came back through the user's browser, so treat them as
|
|
33
|
+
* untrusted even though this server only ever talks to localhost. */
|
|
34
|
+
function escapeHtml(s) {
|
|
35
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
|
|
36
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
37
|
+
}[c]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function page({ ok, error, username }) {
|
|
41
|
+
const title = ok ? 'Signed in' : 'Sign-in failed';
|
|
42
|
+
const body = ok
|
|
43
|
+
? `Signed in${username ? ` as <b>${escapeHtml(username)}</b>` : ''}. You can close this tab and return to the terminal.`
|
|
44
|
+
: `Sign-in failed: ${escapeHtml(error || 'no token was returned')}. You can close this tab and try again.`;
|
|
45
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title} — AEGIS</title></head>` +
|
|
46
|
+
`<body style="font:16px sans-serif;max-width:32rem;margin:4rem auto;text-align:center;">` +
|
|
47
|
+
`<h2>${title}</h2><p>${body}</p></body></html>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `${apiBase}/auth/google?callback=${callbackUrl}` — the one URL this flow opens. */
|
|
51
|
+
function buildAuthUrl(apiBase, callbackUrl) {
|
|
52
|
+
const url = new URL('/auth/google', apiBase);
|
|
53
|
+
url.searchParams.set('callback', callbackUrl);
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Starts the local callback server and returns the URL to open, plus a
|
|
59
|
+
* `wait()` that resolves once the browser lands on it.
|
|
60
|
+
*
|
|
61
|
+
* Only a request that carries `token` or `error` ends the wait — anything
|
|
62
|
+
* else (a browser pre-connect, `/favicon.ico`) gets a plain 404 and the
|
|
63
|
+
* server keeps listening for the real one.
|
|
64
|
+
*
|
|
65
|
+
* @returns {Promise<{url: string, wait: () => Promise<{apiKey:string, username:?string, memoryToken:?string}>}>}
|
|
66
|
+
*/
|
|
67
|
+
async function startGoogleSignIn({ apiBase, host = '127.0.0.1', timeoutMs = 5 * 60 * 1000 } = {}) {
|
|
68
|
+
if (!apiBase) throw new Error('startGoogleSignIn requires apiBase');
|
|
69
|
+
|
|
70
|
+
let settled = false;
|
|
71
|
+
let resolveResult;
|
|
72
|
+
let rejectResult;
|
|
73
|
+
const result = new Promise((resolve, reject) => {
|
|
74
|
+
resolveResult = resolve;
|
|
75
|
+
rejectResult = reject;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const server = http.createServer((req, res) => {
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(req.url, `http://${host}`);
|
|
82
|
+
} catch {
|
|
83
|
+
res.writeHead(400).end();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const token = parsed.searchParams.get('token');
|
|
87
|
+
const username = parsed.searchParams.get('username');
|
|
88
|
+
const memoryToken = parsed.searchParams.get('memory_token');
|
|
89
|
+
const error = parsed.searchParams.get('error');
|
|
90
|
+
|
|
91
|
+
if (!token && !error) {
|
|
92
|
+
res.writeHead(404).end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
96
|
+
res.end(page({ ok: Boolean(token), error, username }));
|
|
97
|
+
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
server.close();
|
|
102
|
+
if (token) resolveResult({ apiKey: token, username: username || null, memoryToken: memoryToken || null });
|
|
103
|
+
else rejectResult(new Error(error || 'no token was returned'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await new Promise((resolve, reject) => {
|
|
107
|
+
server.once('error', reject);
|
|
108
|
+
server.listen(0, host, resolve);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const { port } = server.address();
|
|
112
|
+
const callbackUrl = `http://${host}:${port}`;
|
|
113
|
+
const url = buildAuthUrl(apiBase, callbackUrl);
|
|
114
|
+
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
settled = true;
|
|
118
|
+
server.close();
|
|
119
|
+
rejectResult(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser sign-in`));
|
|
120
|
+
}, timeoutMs);
|
|
121
|
+
// A pending timer must not keep the process alive just for this wait.
|
|
122
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
123
|
+
|
|
124
|
+
return { url, wait: () => result };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { startGoogleSignIn, buildAuthUrl };
|