aegis-desktop 0.8.12 → 0.8.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.
@@ -13,6 +13,7 @@
13
13
  <title>AEGIS Desktop</title>
14
14
  <link rel="stylesheet" href="fonts/fonts.css" />
15
15
  <link rel="stylesheet" href="style.css" />
16
+ <link rel="stylesheet" href="fingerprint.css" />
16
17
  </head>
17
18
  <body>
18
19
  <header class="topbar">
@@ -317,10 +318,68 @@
317
318
  <span id="queue-fanout-label">fan out this task</span>
318
319
  </label>
319
320
  <p class="hint" id="queue-boundary"></p>
321
+ <!-- The auto-drain switch. NOT a hidden timer: it is the persisted
322
+ settings.js `__autoDrain` namespace, off in a default install, read
323
+ by desktop/lib/auto.js on EVERY tick — so unchecking it stops the
324
+ very next one. A drain spends a real model on a real checkout, so
325
+ the switch that authorises one unattended is visible here and stays
326
+ visible while it is armed (the label carries the state). The
327
+ interval is the timer's cadence, clamped in settings.js; the select
328
+ is disabled while the switch is off so a pick can never read as an
329
+ armed timer. -->
330
+ <label
331
+ class="flow-toggle"
332
+ for="queue-auto"
333
+ title="Drain pending tasks on a timer, with no click. Off by default. Each drain runs a full tool loop on a real model in the task's own checkout — the timer only ever drains tasks already in this queue, and main.js refuses the tick if another host (the CLI, a systemd worker) holds the lock."
334
+ >
335
+ <input type="checkbox" id="queue-auto" />
336
+ <span id="queue-auto-label">drain automatically</span>
337
+ </label>
338
+ <div class="api-key-row">
339
+ <select id="queue-auto-interval" title="How often the timer checks the queue. It drains nothing while the queue is empty, and nothing at boot: the first tick is one interval after the switch is armed.">
340
+ <option value="30000">every 30s</option>
341
+ <option value="60000" selected>every minute</option>
342
+ <option value="300000">every 5 minutes</option>
343
+ <option value="900000">every 15 minutes</option>
344
+ </select>
345
+ </div>
346
+ <p class="hint" id="queue-auto-hint"></p>
320
347
  <ul class="sessions-list" id="queue-list"></ul>
321
348
  <p class="hint" id="queue-hint"></p>
322
349
  <div class="queue-upgrade" id="queue-upgrade"></div>
323
350
  </section>
351
+
352
+ <section class="card">
353
+ <!-- φ(α) — the authorship key and the sealed session records.
354
+ The heading is the click target for the full panel, exactly like
355
+ the Memory card (a <button> may only hold phrasing content, so the
356
+ button goes inside the h2, never around it).
357
+
358
+ Everything on this card is driven by desktop/renderer/fingerprint.js
359
+ over the IPC surface preload.js:171-183 already exposes; app.js only
360
+ mounts it. Nothing here ever receives the key itself — the state
361
+ line shows the derived 12-hex tag. -->
362
+ <h2>
363
+ <button
364
+ type="button"
365
+ class="card-title-btn"
366
+ id="fp-open"
367
+ title="Open the authorship panel — key, sealed records, and the Drive vault"
368
+ >
369
+ <span>Authorship</span>
370
+ <span class="ctb-expand" aria-hidden="true">⤢</span>
371
+ </button>
372
+ </h2>
373
+ <p class="hint" id="fp-card-status">checking…</p>
374
+ <!-- One-click seal, the same action as the panel's. `-card` suffix
375
+ because ids are document-global: two nodes sharing `fp-seal` would
376
+ leave whichever the browser returned second permanently unbound. -->
377
+ <button type="button" id="fp-seal-card" class="btn-block">Seal this session</button>
378
+ <p class="hint" id="fp-card-hint">
379
+ A sealed record proves which key wrote a session, and can carry a
380
+ behaviour digest of the code it touched.
381
+ </p>
382
+ </section>
324
383
  </aside>
325
384
 
326
385
  <section class="chat">
@@ -385,7 +444,7 @@
385
444
 
386
445
  The genuinely-free facts — exec/write/edit against your real
387
446
  working tree, repos and keys on this disk, a local model server
388
- with no account, an MIT client — are NOT reasons to pay, so they
447
+ with no account, an open-source client — are NOT reasons to pay, so they
389
448
  moved into the free path copy below instead of standing in the
390
449
  offer.
391
450
 
@@ -587,6 +646,105 @@
587
646
  </div>
588
647
  </div>
589
648
 
649
+ <!-- φ(α) panel. Same sheet as the memory inspector (the classes are shared on
650
+ purpose: one translucent layer, one Escape policy). Every control is
651
+ bound in fingerprint.js, which is also where these ids are declared — a
652
+ typo in either place fails test/renderer-fingerprint.test.mjs. Nothing
653
+ rendered here is ever the key itself. -->
654
+ <div class="memory-overlay fp-overlay" id="fp-overlay" hidden>
655
+ <div class="memory-backdrop" id="fp-backdrop"></div>
656
+ <div
657
+ class="memory-panel"
658
+ role="dialog"
659
+ aria-modal="true"
660
+ aria-labelledby="fp-panel-title"
661
+ >
662
+ <header class="memory-panel-head">
663
+ <h2 id="fp-panel-title">Authorship φ(α)</h2>
664
+ <span class="memory-count" id="fp-key-tag">none</span>
665
+ <div class="spacer"></div>
666
+ <button type="button" id="fp-close" class="ghost-btn" title="Close (Esc)">
667
+ Close
668
+ </button>
669
+ </header>
670
+
671
+ <div class="fp-rows">
672
+ <div class="fp-row"><span class="fp-k">source</span><span class="fp-v" id="fp-key-source">–</span></div>
673
+ <div class="fp-row"><span class="fp-k">key file</span><span class="fp-v" id="fp-key-path">–</span></div>
674
+ <div class="fp-row"><span class="fp-k">publishes to</span><span class="fp-v" id="fp-vault-target">–</span></div>
675
+ </div>
676
+ <p class="fp-warn" id="fp-key-warning" hidden></p>
677
+
678
+ <div class="fp-controls">
679
+ <div class="fp-line">
680
+ <input
681
+ type="text"
682
+ id="fp-label"
683
+ placeholder="label for the record (optional)"
684
+ autocomplete="off"
685
+ />
686
+ <button type="button" id="fp-keygen" class="ghost-btn">Mint key</button>
687
+ </div>
688
+ <label
689
+ class="flow-toggle"
690
+ for="fp-publish-on-mint"
691
+ title="Copy the sealed bytes to gdrive:AEGIS-AGPL/records and write the Drive link beside the local record. The key never travels."
692
+ >
693
+ <input type="checkbox" id="fp-publish-on-mint" />
694
+ <span>publish to the vault when sealing</span>
695
+ </label>
696
+ </div>
697
+
698
+ <div class="fp-actions">
699
+ <button type="button" id="fp-seal" class="ghost-btn">Seal this session</button>
700
+ <button type="button" id="fp-verify" class="ghost-btn">Verify</button>
701
+ <button type="button" id="fp-publish" class="ghost-btn">Publish</button>
702
+ <button type="button" id="fp-anchor" class="ghost-btn">Check anchor</button>
703
+ <button type="button" id="fp-vault" class="ghost-btn">List vault</button>
704
+ <button type="button" id="fp-audit" class="ghost-btn">Audit vault</button>
705
+ </div>
706
+
707
+ <!-- The auto-seal switch (settings.js `__autoMint`, off by default).
708
+ "Quiet", not "every turn": the timer restarts on every finished turn
709
+ and fires 90s after the LAST one, so one session files one record
710
+ that carries all of its turns — human axis and AI axis alike. A
711
+ manual "Seal now" resets the same counter, so a session is never
712
+ sealed twice. -->
713
+ <div class="fp-controls">
714
+ <label
715
+ class="flow-toggle"
716
+ for="fp-auto-seal"
717
+ title="Seal this session as a φ(α) record once it has been quiet for 90 seconds. Off by default. The seal happens whether or not anyone is watching, but publishing to the vault stays a separate checkbox."
718
+ >
719
+ <input type="checkbox" id="fp-auto-seal" />
720
+ <span id="fp-auto-seal-label">seal automatically when the session goes quiet</span>
721
+ </label>
722
+ <div class="fp-line">
723
+ <button type="button" id="fp-seal-now" class="ghost-btn">Seal now</button>
724
+ </div>
725
+ <p class="hint" id="fp-auto-hint"></p>
726
+ </div>
727
+
728
+ <!-- Class, not a style attribute: the window's CSP is `style-src 'self'`,
729
+ which drops inline styles silently — the same failure mode the
730
+ script-src note at the top of this file warns about for handlers. -->
731
+ <div class="fp-line fp-line-inset">
732
+ <input
733
+ type="text"
734
+ id="fp-backup-to"
735
+ placeholder="/path/for/a/key/copy"
736
+ autocomplete="off"
737
+ title="Copy the key out. Refused inside a git tree that does not ignore it, and never into cloud storage — a key in a synced folder can mint records indistinguishable from yours."
738
+ />
739
+ <button type="button" id="fp-backup" class="ghost-btn">Back up key</button>
740
+ </div>
741
+
742
+ <ul class="fp-vault-list" id="fp-vault-list"></ul>
743
+
744
+ <div class="fp-out" id="fp-out">Ready. The key is never displayed.</div>
745
+ </div>
746
+ </div>
747
+
590
748
  <script src="budget.js"></script>
591
749
  <script src="usage.js"></script>
592
750
  <script src="stream-policy.js"></script>
@@ -597,6 +755,10 @@
597
755
  <script src="vendor/purify.min.js"></script>
598
756
  <script src="vendor/aegis-highlight.js"></script>
599
757
  <script src="markdown.js"></script>
758
+ <!-- Loaded before app.js: app.js's init() calls window.aegisFingerprint.mount
759
+ and composes its overlay into the Escape policy, and the orphan check in
760
+ test/renderer-wiring.test.mjs fails if this tag goes missing. -->
761
+ <script src="fingerprint.js"></script>
600
762
  <script src="app.js"></script>
601
763
  </body>
602
764
  </html>
package/vendor/aegis.js CHANGED
@@ -116,6 +116,26 @@ function randomUUID() {
116
116
  const DEFAULT_API_BASE = 'https://aegiscloud.org';
117
117
  const CLIENT_VERSION = '3.2.0';
118
118
 
119
+ /**
120
+ * Every non-streaming request to aegis1 is bounded, and so is its body.
121
+ *
122
+ * The stream path has always had an idle watchdog (`readWithIdleTimeout`), but
123
+ * the plain `apiPost`/`apiGet` calls — account state, memory, models, billing —
124
+ * had none: `fetch` on a connection the server accepted and then never answered
125
+ * waits forever. That is not hypothetical for this backend. aegis1 runs under
126
+ * `gunicorn --worker-class gevent`, where a greenlet blocked on an upstream
127
+ * provider call can hold a request open with no reply and no error, and the
128
+ * host that called it hangs with no way out but force-quit. A caller-supplied
129
+ * signal (if any) still works; this only adds a ceiling where there was none.
130
+ */
131
+ const DEFAULT_TIMEOUT_MS = 60000;
132
+ /**
133
+ * Cap on a single response body. A non-streaming endpoint that answers with an
134
+ * unbounded body (a misrouted download, a proxy error page echoing a request
135
+ * loop) would otherwise be read entirely into memory by `res.text()`.
136
+ */
137
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
138
+
119
139
  /**
120
140
  * .mcp.json / Electron pass config as "${VAR}" template refs. When the var is
121
141
  * unset, hosts have been observed to leave the template literally unexpanded
@@ -141,8 +161,127 @@ function createClient(opts = {}) {
141
161
  // hence the allowlist rather than trusting the caller.
142
162
  const channel = opts.channel === 'cli' ? 'cli' : 'desktop';
143
163
 
164
+ /**
165
+ * Strip the live API key out of anything on its way to a message, a log or a
166
+ * renderer. Server error bodies echo the offending request often enough that
167
+ * this is not theoretical: a 400 that repeats the request payload puts the
168
+ * key into `err.message`, which hosts print to a terminal, a log file, and
169
+ * (desktop) across an IPC boundary. The key is the one value in this module
170
+ * whose leak is unrecoverable, so it is scrubbed at the boundary rather than
171
+ * trusted not to appear.
172
+ */
173
+ function redact(value) {
174
+ if (!apiKey || typeof value !== 'string') return value;
175
+ return value.split(apiKey).join('[redacted]');
176
+ }
177
+
178
+ /**
179
+ * A bounded `fetch`: caller signal where there is one, an aborting timeout
180
+ * where there is not. Returns the caller's abort untouched (so a host that
181
+ * cancelled a turn still sees its own cancellation, not a timeout), and
182
+ * raises a typed `timeout` error for the ceiling it added itself.
183
+ */
184
+ async function boundedFetch(url, init) {
185
+ const callerSignal = init && init.signal;
186
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
187
+ let timer = null;
188
+ const onAbort = () => {
189
+ if (controller) controller.abort();
190
+ };
191
+ if (controller) {
192
+ // `unref` where available: a pending timeout must never be the reason a
193
+ // short-lived CLI process (`aegiscode -p "..."`) refuses to exit.
194
+ timer = setTimeout(() => controller.abort(), timeoutMs);
195
+ if (timer && typeof timer.unref === 'function') timer.unref();
196
+ if (callerSignal) {
197
+ if (callerSignal.aborted) controller.abort();
198
+ else if (typeof callerSignal.addEventListener === 'function') {
199
+ callerSignal.addEventListener('abort', onAbort, { once: true });
200
+ }
201
+ }
202
+ }
203
+ try {
204
+ return await fetch(url, {
205
+ ...(init || {}),
206
+ signal: controller ? controller.signal : callerSignal,
207
+ });
208
+ } catch (err) {
209
+ // Only claim a timeout when *we* aborted. A caller-originated abort is
210
+ // the caller's business and is rethrown as-is.
211
+ const ours =
212
+ controller && controller.signal.aborted && !(callerSignal && callerSignal.aborted);
213
+ if (ours) {
214
+ const e = new Error(
215
+ `Timed out after ${timeoutMs / 1000}s waiting for ${apiBase}.`
216
+ );
217
+ e.status = 0;
218
+ e.code = 'timeout';
219
+ e.data = null;
220
+ throw e;
221
+ }
222
+ throw err;
223
+ } finally {
224
+ if (timer) clearTimeout(timer);
225
+ if (controller && callerSignal && typeof callerSignal.removeEventListener === 'function') {
226
+ callerSignal.removeEventListener('abort', onAbort);
227
+ }
228
+ }
229
+ }
230
+
231
+ /** Read a response body, refusing to buffer more than MAX_BODY_BYTES. */
232
+ async function readCappedText(res) {
233
+ if (!res || !res.body || typeof res.body.getReader !== 'function') {
234
+ const text = await res.text();
235
+ return text.length > maxBodyBytes ? text.slice(0, maxBodyBytes) : text;
236
+ }
237
+ const reader = res.body.getReader();
238
+ const decoder = new TextDecoder();
239
+ let out = '';
240
+ let total = 0;
241
+ try {
242
+ for (;;) {
243
+ const { done, value } = await reader.read();
244
+ if (done) break;
245
+ total += value ? value.byteLength : 0;
246
+ if (total > maxBodyBytes) {
247
+ const e = new Error(
248
+ `Response from ${apiBase} exceeded the ${maxBodyBytes / (1024 * 1024)}MB cap.`
249
+ );
250
+ e.status = res.status;
251
+ e.code = 'body_too_large';
252
+ e.data = null;
253
+ throw e;
254
+ }
255
+ out += decoder.decode(value, { stream: true });
256
+ }
257
+ out += decoder.decode();
258
+ return out;
259
+ } finally {
260
+ // Release the socket on every path, including the cap trip and any
261
+ // decoder throw — otherwise a rejected body keeps the connection alive.
262
+ try {
263
+ await reader.cancel();
264
+ } catch {
265
+ /* already released */
266
+ }
267
+ }
268
+ }
269
+
144
270
  let memoryTokenCache = null;
145
271
 
272
+ // Injectable ceilings. A guard that can only be exercised by sleeping for a
273
+ // minute, or by allocating 9MB, is a guard that quietly rots — so the
274
+ // transport takes both limits as options (and an env override, so a host can
275
+ // tighten them without a code change). The defaults are the production ones.
276
+ const timeoutMs =
277
+ Number(opts.timeoutMs) > 0
278
+ ? Number(opts.timeoutMs)
279
+ : Number(envVar('AEGIS_HTTP_TIMEOUT_MS')) > 0
280
+ ? Number(envVar('AEGIS_HTTP_TIMEOUT_MS'))
281
+ : DEFAULT_TIMEOUT_MS;
282
+ const maxBodyBytes =
283
+ Number(opts.maxBodyBytes) > 0 ? Number(opts.maxBodyBytes) : MAX_BODY_BYTES;
284
+
146
285
  /**
147
286
  * A campaign-tagged upgrade link. Never hand-build a `/subscribe` URL in a
148
287
  * prompt: the bare form carries no attribution, so the click arrives as
@@ -190,7 +329,7 @@ function createClient(opts = {}) {
190
329
  }
191
330
 
192
331
  async function apiPost(path, body, headers, opts) {
193
- const res = await fetch(`${apiBase}${path}`, {
332
+ const res = await boundedFetch(`${apiBase}${path}`, {
194
333
  method: 'POST',
195
334
  headers: headers || authHeaders(),
196
335
  body: JSON.stringify(body || {}),
@@ -199,10 +338,11 @@ function createClient(opts = {}) {
199
338
  return parseResponse(res);
200
339
  }
201
340
 
202
- async function apiGet(path, headers) {
203
- const res = await fetch(`${apiBase}${path}`, {
341
+ async function apiGet(path, headers, opts) {
342
+ const res = await boundedFetch(`${apiBase}${path}`, {
204
343
  method: 'GET',
205
344
  headers: headers || authHeaders(),
345
+ signal: opts && opts.signal,
206
346
  });
207
347
  return parseResponse(res);
208
348
  }
@@ -265,12 +405,14 @@ function createClient(opts = {}) {
265
405
  }
266
406
 
267
407
  async function parseResponse(res) {
268
- const text = await res.text();
408
+ const text = await readCappedText(res);
269
409
  let data;
270
410
  try {
271
411
  data = text ? JSON.parse(text) : {};
272
412
  } catch {
273
- data = { raw: text };
413
+ // An unparseable body is often a proxy/error page that quotes the
414
+ // request, so it goes through redaction too.
415
+ data = { raw: redact(text) };
274
416
  }
275
417
  if (!res.ok) {
276
418
  if (isVersionGate(res.status, data)) {
@@ -279,7 +421,7 @@ function createClient(opts = {}) {
279
421
  const msg =
280
422
  (data && (data.error?.message || data.error || data.message)) ||
281
423
  `HTTP ${res.status}`;
282
- const err = new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
424
+ const err = new Error(redact(typeof msg === 'string' ? msg : JSON.stringify(msg)));
283
425
  err.status = res.status;
284
426
  err.data = data;
285
427
  throw err;
@@ -314,7 +456,7 @@ function createClient(opts = {}) {
314
456
  if (data && data.code === 'version_mismatch') {
315
457
  throw versionGateError(data, status || 426);
316
458
  }
317
- const msg = errorMessageOf(data);
459
+ const msg = redact(errorMessageOf(data));
318
460
  if (!msg) return;
319
461
  const err = new Error(msg);
320
462
  err.status = status || 500;
@@ -589,7 +731,13 @@ function createClient(opts = {}) {
589
731
  async function postStream(path, body, headers, onStream, signal, streamOpts) {
590
732
  const { onReasoning, idleTimeoutMs, retryWithoutStreamOptions } = streamOpts || {};
591
733
  const request = (payload) =>
592
- fetch(`${apiBase}${path}`, {
734
+ // `boundedFetch` here bounds time-to-*headers* only: fetch resolves when
735
+ // the status line lands, and the timer is cleared in its `finally`, so a
736
+ // legitimately long generation is never cut off by it. What it does fix is
737
+ // the gap the idle watchdog cannot reach — a server that accepts the
738
+ // socket and never sends response headers, where `reader.read()` has not
739
+ // been reached yet and so nothing is watching at all.
740
+ boundedFetch(`${apiBase}${path}`, {
593
741
  method: 'POST',
594
742
  headers,
595
743
  body: JSON.stringify({ ...payload, stream: true }),
@@ -611,7 +759,9 @@ function createClient(opts = {}) {
611
759
  if (!res.ok && res.status === 426) {
612
760
  let gateData = {};
613
761
  try {
614
- const t = await res.text();
762
+ // Capped like every other body read: a gate response is still a
763
+ // response from a host we do not control the size of.
764
+ const t = await readCappedText(res);
615
765
  gateData = t ? JSON.parse(t) : {};
616
766
  } catch {
617
767
  gateData = {}; // a gate with an unreadable body is still a gate
@@ -646,7 +796,12 @@ function createClient(opts = {}) {
646
796
  // it on would turn this graceful fallback into a second rejection.
647
797
  const cleanBody = { ...body };
648
798
  delete cleanBody.stream_options;
649
- const data = await apiPost(path, { ...cleanBody, stream: false }, headers);
799
+ // `signal` is forwarded here too: this is the *non-stream fallback* of a
800
+ // stream call, i.e. the path taken when the server answered a streaming
801
+ // request with plain JSON. Dropping the signal here meant an aborted turn
802
+ // kept waiting on a request nobody was listening for any more — the
803
+ // caller had already moved on, and the host held the socket.
804
+ const data = await apiPost(path, { ...cleanBody, stream: false }, headers, { signal });
650
805
  const fullText = textOf(data);
651
806
  if (fullText) onStream({ delta: fullText });
652
807
  return data;
@@ -761,6 +916,7 @@ function createClient(opts = {}) {
761
916
  typeof e === 'string'
762
917
  ? e
763
918
  : errorMessageOf({ error: e }) || 'stream error';
919
+ sseError = redact(sseError);
764
920
  }
765
921
  continue;
766
922
  }
@@ -861,12 +1017,18 @@ function createClient(opts = {}) {
861
1017
  return result;
862
1018
  }
863
1019
 
864
- async function listModels() {
865
- return apiGet('/api/v1/models');
1020
+ /**
1021
+ * The read-only GETs take an optional `opts` so a host can cancel them.
1022
+ * Without it no non-streaming call in this module could be aborted at all:
1023
+ * the signal had nowhere to travel, so `boundedFetch` always fell back to its
1024
+ * own ceiling and a caller's cancel was silently ignored.
1025
+ */
1026
+ async function listModels(opts) {
1027
+ return apiGet('/api/v1/models', null, opts);
866
1028
  }
867
1029
 
868
- async function tokenBankBalance() {
869
- return apiGet('/api/token-bank/balance');
1030
+ async function tokenBankBalance(opts) {
1031
+ return apiGet('/api/token-bank/balance', null, opts);
870
1032
  }
871
1033
 
872
1034
  /** Start a token-bank top-up; resolves to { url } for the payment page.
@@ -884,8 +1046,8 @@ function createClient(opts = {}) {
884
1046
  return apiPost('/api/billing/checkout', {});
885
1047
  }
886
1048
 
887
- async function byokStatus() {
888
- return apiGet('/api/user/api-keys');
1049
+ async function byokStatus(opts) {
1050
+ return apiGet('/api/user/api-keys', null, opts);
889
1051
  }
890
1052
 
891
1053
  /** The server's BYOK provider catalog: which providers accept a key, which
@@ -893,8 +1055,8 @@ function createClient(opts = {}) {
893
1055
  * starts with. Deliberately NOT key-gated — "which key do I go get, and
894
1056
  * what will it unlock?" is the question asked *before* an AEGIS key exists.
895
1057
  * An AEGIS key, if present, only adds the `configured` column. */
896
- async function byokProviders() {
897
- return apiGet('/api/v1/byok/providers');
1058
+ async function byokProviders(opts) {
1059
+ return apiGet('/api/v1/byok/providers', null, opts);
898
1060
  }
899
1061
 
900
1062
  async function byokSet(provider, providerApiKey) {