aegis-desktop 0.8.17 → 0.8.18
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/local/engine.js +29 -36
- package/package.json +1 -1
- package/renderer/app.js +227 -0
- package/renderer/index.html +82 -0
- package/renderer/style.css +74 -0
- package/vendor/credentials.js +94 -6
- package/vendor/env-file.js +161 -5
package/lib/local/engine.js
CHANGED
|
@@ -307,35 +307,32 @@ function normalizeCatalog(models) {
|
|
|
307
307
|
/**
|
|
308
308
|
* The Aegis Cloud (pooled) dropdown.
|
|
309
309
|
*
|
|
310
|
-
* It offers
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
310
|
+
* It offers the server's whole catalog (`/api/v1/models`): the pooled brain
|
|
311
|
+
* first, under its one name (Nexus), then every per-provider id the account can
|
|
312
|
+
* pin (`deepseek`, `anthropic`, `groq`, `openai`, ...).
|
|
313
|
+
*
|
|
314
|
+
* Two narrower shapes were shipped here and both are reverted, because both
|
|
315
|
+
* produced the same report — a user asking where the models went. First the
|
|
316
|
+
* class collapsed to a single "Nexus" row (`filterAegisCatalog`), which ended
|
|
317
|
+
* the two-host disagreement about the brain's five tier spellings by removing
|
|
318
|
+
* the choice: a funded account had `deepseek` advertised to it and no way to
|
|
319
|
+
* pin it. Then it offered nothing at all — `omitAegisCloudModels` composed with
|
|
320
|
+
* that collapse returns `[]`, because the omission removes the only entry the
|
|
321
|
+
* collapse looks for. The rule both hosts call now is `offerableCatalog`:
|
|
322
|
+
* everything the server advertises, the tier spellings folded into the one row
|
|
323
|
+
* they point at.
|
|
319
324
|
*
|
|
320
325
|
* What is NOT changed: the account key, and everything the key is *for* — the
|
|
321
326
|
* pooled turn itself, cloud memory sync, and the BYOK handling fee that is
|
|
322
|
-
* billed to this account (see the byok branch below
|
|
323
|
-
* provider id the server's catalog names). Omitting a model list is not
|
|
324
|
-
* disconnecting the lane.
|
|
325
|
-
*
|
|
326
|
-
* The omission itself is one shared rule (`omitAegisCloudModels`, and the
|
|
327
|
-
* collapse it is composed with) in `client/brain-catalog.js`, shared with the
|
|
328
|
-
* CLI: two copies is how this host and the terminal previously came to offer
|
|
329
|
-
* the same account two different model lists. Resolved the two ways this repo
|
|
330
|
-
* resolves every shared module — repo-relative in a checkout, and this app's
|
|
331
|
-
* staged vendor/ tree.
|
|
327
|
+
* billed to this account (see the byok branch below).
|
|
332
328
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
329
|
+
* The list itself is one shared rule in `client/brain-catalog.js`, shared with
|
|
330
|
+
* the CLI: two copies is how this host and the terminal previously came to
|
|
331
|
+
* offer the same account two different model lists. Resolved the two ways this
|
|
332
|
+
* repo resolves every shared module — repo-relative in a checkout, and this
|
|
333
|
+
* app's staged vendor/ tree.
|
|
337
334
|
*/
|
|
338
|
-
const {
|
|
335
|
+
const { offerableCatalog } = requireSharedBrain();
|
|
339
336
|
|
|
340
337
|
/**
|
|
341
338
|
* The BYOK additions, applied to the server's provider catalog below. Same
|
|
@@ -855,20 +852,16 @@ function createLocalEngine({
|
|
|
855
852
|
// entry so the class is usable the moment a key lands.
|
|
856
853
|
if (!aegis.apiKey) return { class: cls, models: [], needsKey: true };
|
|
857
854
|
const data = await aegis.listModels();
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
// emptied, which is why `needsKey` keeps its meaning and the renderer's
|
|
861
|
-
// hint for this class is unchanged.
|
|
855
|
+
// The whole catalog, aliases folded — see the rule above. What the list
|
|
856
|
+
// contains does not touch `needsKey` or the renderer's per-class hint.
|
|
862
857
|
return {
|
|
863
858
|
class: cls,
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
870
|
-
// case the rule exists to catch.
|
|
871
|
-
models: filterAegisCatalog(omitAegisCloudModels(normalizeCatalog(data && data.models))),
|
|
859
|
+
// One call, because the folding has to see the payload's own `alias_of`
|
|
860
|
+
// bookkeeping: a pre-collapsed row IS the selection, so a renderer must
|
|
861
|
+
// not filter it back out as a hidden alias, and folding what has already
|
|
862
|
+
// been folded would leave a renamed tier (`nexus-brain-v2`, an alias OF
|
|
863
|
+
// the brain under a spelling this release has never seen) on screen.
|
|
864
|
+
models: offerableCatalog(normalizeCatalog(data && data.models)),
|
|
872
865
|
};
|
|
873
866
|
}
|
|
874
867
|
if (cls === 'byok') {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.18",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
package/renderer/app.js
CHANGED
|
@@ -161,6 +161,19 @@ const ELEMENT_IDS = {
|
|
|
161
161
|
queueAutoHint: 'queue-auto-hint',
|
|
162
162
|
queueBoundary: 'queue-boundary',
|
|
163
163
|
queueUpgrade: 'queue-upgrade',
|
|
164
|
+
// The two-key pop-up (index.html #keys-overlay, driven by applyKeysPopup()).
|
|
165
|
+
keysOverlay: 'keys-overlay',
|
|
166
|
+
keysBackdrop: 'keys-backdrop',
|
|
167
|
+
keysClose: 'keys-close',
|
|
168
|
+
keysCount: 'keys-count',
|
|
169
|
+
keysRowAegis: 'keys-row-aegis',
|
|
170
|
+
keysRowByok: 'keys-row-byok',
|
|
171
|
+
keysAegisState: 'keys-aegis-state',
|
|
172
|
+
keysByokState: 'keys-byok-state',
|
|
173
|
+
keysGotoAegis: 'keys-goto-aegis',
|
|
174
|
+
keysGotoByok: 'keys-goto-byok',
|
|
175
|
+
keysHint: 'keys-hint',
|
|
176
|
+
keysDismiss: 'keys-dismiss',
|
|
164
177
|
};
|
|
165
178
|
|
|
166
179
|
const els = {};
|
|
@@ -895,6 +908,10 @@ function renderStatus(s) {
|
|
|
895
908
|
// from the CLI) retires the connect block; with no key it stays. No-ops when
|
|
896
909
|
// the panel is not mounted.
|
|
897
910
|
applyWelcomeConnect();
|
|
911
|
+
// And the two-key pop-up's gate: a status read is the only thing that knows
|
|
912
|
+
// either fact, so this is where the sheet is opened (a key still missing) or
|
|
913
|
+
// retired (both present). No-ops while the markup is absent.
|
|
914
|
+
applyKeysPopup();
|
|
898
915
|
}
|
|
899
916
|
|
|
900
917
|
// -------------------------------------------------------------- auto-update
|
|
@@ -1828,6 +1845,9 @@ const memoryView = {
|
|
|
1828
1845
|
* instead. Confusing the two is how a save would read a hidden textarea. */
|
|
1829
1846
|
function overlayOpen() {
|
|
1830
1847
|
if (memoryOverlayOpen()) return true;
|
|
1848
|
+
// The two-key pop-up is a sheet on the same layer, so Escape belongs to it
|
|
1849
|
+
// first while it is up — the same rule the memory inspector follows.
|
|
1850
|
+
if (keysOverlayOpen()) return true;
|
|
1831
1851
|
// Ask the PANEL, not the module.
|
|
1832
1852
|
//
|
|
1833
1853
|
// `window.aegisFingerprint` is desktop/renderer/fingerprint.js, whose exports
|
|
@@ -1858,6 +1878,12 @@ function memoryOverlayOpen() {
|
|
|
1858
1878
|
return !!els.memoryOverlay && !els.memoryOverlay.hidden;
|
|
1859
1879
|
}
|
|
1860
1880
|
|
|
1881
|
+
/** The two-key pop-up alone. Same shape as memoryOverlayOpen(): the element,
|
|
1882
|
+
* not a module variable, so a sheet closed by any path reads as closed. */
|
|
1883
|
+
function keysOverlayOpen() {
|
|
1884
|
+
return !!els.keysOverlay && !els.keysOverlay.hidden;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1861
1887
|
/** The φ(α) panel handle, set by init() when desktop/renderer/fingerprint.js is
|
|
1862
1888
|
* loaded. Kept here rather than inside fingerprint.js so the Escape policy
|
|
1863
1889
|
* above can ask it whether it is open without reaching into its internals. */
|
|
@@ -2590,6 +2616,162 @@ function dismissWelcomeConnect() {
|
|
|
2590
2616
|
if (box) box.hidden = true;
|
|
2591
2617
|
}
|
|
2592
2618
|
|
|
2619
|
+
// ------------------------------------------- boot: the two-key pop-up
|
|
2620
|
+
//
|
|
2621
|
+
// The welcome block above pitches the ROUTES ("connect AEGIS Cloud", "bring
|
|
2622
|
+
// your own key") and retires the moment either one is taken. This sheet is the
|
|
2623
|
+
// other question, and the one nothing on screen used to answer: this app and
|
|
2624
|
+
// the `aegiscode` CLI on the command line are ONE engine over ONE store, so a
|
|
2625
|
+
// user who added only one of the two keys gets a surface that refuses turns
|
|
2626
|
+
// with no explanation of the other half.
|
|
2627
|
+
//
|
|
2628
|
+
// AEGIS key — `aegis:<...>` in ~/.aegiscode/.env (AEGIS_API_KEY), the
|
|
2629
|
+
// account key. The CLI writes it with `/key`, this app with
|
|
2630
|
+
// the Status card's field. Every turn on both surfaces needs
|
|
2631
|
+
// it: BYOK sends are relayed through AEGIS and bill a handling
|
|
2632
|
+
// fee to it, so a keyless BYOK send is refused outright.
|
|
2633
|
+
// BYOK key — a `byok:<provider>` row in the shared settings store. The
|
|
2634
|
+
// CLI writes it with `/byok-key <provider>`, this app with the
|
|
2635
|
+
// per-provider row in Provider settings. It is the free path:
|
|
2636
|
+
// your own provider account, on either surface.
|
|
2637
|
+
//
|
|
2638
|
+
// Both are reported by the main process in the status payload — keyConfigured
|
|
2639
|
+
// (also read here for the top-up guard) and providerConfigured (main.js
|
|
2640
|
+
// providerRouteConfigured(), documented at desktop/lib/provider-key-mirror.js)
|
|
2641
|
+
// — so the sheet is driven by one read, not by a second probe of its own.
|
|
2642
|
+
//
|
|
2643
|
+
// When it opens: on the status payload, while EITHER key is missing. That is
|
|
2644
|
+
// the ask the copy makes ("add both — both surfaces run"), so retiring it on
|
|
2645
|
+
// the first key would contradict the sentence the user just read. It is a
|
|
2646
|
+
// pop-up and therefore dismissible: `aegis.keysPopupDismissed` is persisted
|
|
2647
|
+
// and read on every repaint, exactly like the welcome block's flag, because a
|
|
2648
|
+
// sheet that reappears after "don't show this again" is a nag, not onboarding.
|
|
2649
|
+
// While the key state is still unknown (null, i.e. status has not been read)
|
|
2650
|
+
// it stays closed: a cold boot has no key far more often than not, but opening
|
|
2651
|
+
// a sheet on a guess is how the app would flash a pop-up at someone who is
|
|
2652
|
+
// fully configured. renderStatus() calls the gate again the moment the truth
|
|
2653
|
+
// arrives, which is what actually opens it.
|
|
2654
|
+
|
|
2655
|
+
/** The dismiss flag. Read, not remembered — the sheet is re-gated on every
|
|
2656
|
+
* status repaint, so a module variable alone would bring it back. */
|
|
2657
|
+
const KEYS_POPUP_DISMISS_KEY = 'aegis.keysPopupDismissed';
|
|
2658
|
+
|
|
2659
|
+
/** True while either key is still missing — the sheet's whole question. Both
|
|
2660
|
+
* facts default to "missing" for an unknown state, matching the fail-toward-
|
|
2661
|
+
* showing-it bias the welcome block and the CLI's connectNeeded() share; the
|
|
2662
|
+
* null check is in the gate below, not here, so this stays a plain predicate
|
|
2663
|
+
* about two booleans. */
|
|
2664
|
+
function keysPopupNeeded() {
|
|
2665
|
+
return keyConfigured !== true || providerConfigured !== true;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/** Paint the two state lines and the count. Never the keys themselves — only
|
|
2669
|
+
* "set" / "not set", the same rule the φ(α) panel follows. */
|
|
2670
|
+
function renderKeysPopup() {
|
|
2671
|
+
const aegisSet = keyConfigured === true;
|
|
2672
|
+
const byokSet = providerConfigured === true;
|
|
2673
|
+
if (els.keysAegisState) els.keysAegisState.textContent = aegisSet ? 'set' : 'not set';
|
|
2674
|
+
if (els.keysByokState) els.keysByokState.textContent = byokSet ? 'set' : 'not set';
|
|
2675
|
+
if (els.keysRowAegis) els.keysRowAegis.classList.toggle('set', aegisSet);
|
|
2676
|
+
if (els.keysRowByok) els.keysRowByok.classList.toggle('set', byokSet);
|
|
2677
|
+
if (els.keysCount) els.keysCount.textContent = `${(aegisSet ? 1 : 0) + (byokSet ? 1 : 0)} / 2`;
|
|
2678
|
+
if (els.keysHint) {
|
|
2679
|
+
els.keysHint.textContent =
|
|
2680
|
+
aegisSet && byokSet
|
|
2681
|
+
? 'Both keys are on this machine — both surfaces run.'
|
|
2682
|
+
: `Missing: ${[aegisSet ? null : 'the AEGIS key', byokSet ? null : 'your provider (BYOK) key']
|
|
2683
|
+
.filter(Boolean)
|
|
2684
|
+
.join(' and ')}. Add it above, then both AEGIS Desktop and the aegiscode CLI run.`;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/** Show the sheet. Idempotent, so the gate can call it on every repaint. */
|
|
2689
|
+
function openKeysPopup() {
|
|
2690
|
+
if (!els.keysOverlay) return;
|
|
2691
|
+
renderKeysPopup();
|
|
2692
|
+
if (!els.keysOverlay.hidden) return;
|
|
2693
|
+
els.keysOverlay.hidden = false;
|
|
2694
|
+
document.body.classList.add('keys-open');
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function closeKeysPopup() {
|
|
2698
|
+
if (!els.keysOverlay) return;
|
|
2699
|
+
els.keysOverlay.hidden = true;
|
|
2700
|
+
document.body.classList.remove('keys-open');
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/** True while a turn is in flight — the one fact that outranks this sheet.
|
|
2704
|
+
*
|
|
2705
|
+
* Why this exists, and why it is a refusal rather than a lower z-index: the
|
|
2706
|
+
* sheet is composed into overlayOpen(), so while it is up Escape belongs to it
|
|
2707
|
+
* and is spent closing it — that is the contract every other sheet in this
|
|
2708
|
+
* file follows, and it is right for a sheet the USER opened. This one opens
|
|
2709
|
+
* ITSELF, on a status read, whenever a key is missing. So on a fresh or
|
|
2710
|
+
* half-configured install it is up before the first token, and Escape during a
|
|
2711
|
+
* streaming turn would close a pop-up instead of stopping the turn: the user's
|
|
2712
|
+
* only keyboard route to interrupting a runaway turn, silently spent on a
|
|
2713
|
+
* sheet they never asked for. It was found exactly that way — the headless
|
|
2714
|
+
* smoke run stopped failing to stop, with `overlayOpen: true` in the recorded
|
|
2715
|
+
* escape decision.
|
|
2716
|
+
*
|
|
2717
|
+
* An advisory sheet must yield to the turn, so the gate never opens one while
|
|
2718
|
+
* a turn is running, and the send path closes any that is already up. Nothing
|
|
2719
|
+
* is lost: the sheet is re-gated on the next status read after the turn ends,
|
|
2720
|
+
* which is a repaint that happens anyway. */
|
|
2721
|
+
function turnInFlight() {
|
|
2722
|
+
return Boolean(pendingSessionId);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
/** The gate: called on every status read (and once after the key panel is
|
|
2726
|
+
* painted). Dismissed wins; an unknown key state defers to the next read. */
|
|
2727
|
+
function applyKeysPopup() {
|
|
2728
|
+
const box = document.getElementById('keys-overlay');
|
|
2729
|
+
if (!box) return;
|
|
2730
|
+
let dismissed = false;
|
|
2731
|
+
try {
|
|
2732
|
+
dismissed = localStorage.getItem(KEYS_POPUP_DISMISS_KEY) === 'on';
|
|
2733
|
+
} catch {
|
|
2734
|
+
/* storage disabled — treat as not dismissed, same as the welcome block */
|
|
2735
|
+
}
|
|
2736
|
+
if (dismissed || keyConfigured === null) {
|
|
2737
|
+
box.hidden = true;
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
// Repaint before deciding: the two rows and the count describe the keys that
|
|
2741
|
+
// exist right now, and a repaint is what retires the last missing one — so a
|
|
2742
|
+
// sheet that has just become fully configured cannot close still reading
|
|
2743
|
+
// "1 / 2" if it is opened again this session.
|
|
2744
|
+
renderKeysPopup();
|
|
2745
|
+
// A running turn outranks the ask. Close on the way past rather than merely
|
|
2746
|
+
// declining to open, so a sheet already on screen when the turn began cannot
|
|
2747
|
+
// keep the next Escape for itself (see turnInFlight()).
|
|
2748
|
+
if (turnInFlight()) {
|
|
2749
|
+
closeKeysPopup();
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
if (keysPopupNeeded()) openKeysPopup();
|
|
2753
|
+
else closeKeysPopup();
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/** "Don't show this again": persisted, then dropped for this session. */
|
|
2757
|
+
function dismissKeysPopup() {
|
|
2758
|
+
try {
|
|
2759
|
+
localStorage.setItem(KEYS_POPUP_DISMISS_KEY, 'on');
|
|
2760
|
+
} catch {
|
|
2761
|
+
/* storage disabled — the sheet simply comes back next launch */
|
|
2762
|
+
}
|
|
2763
|
+
closeKeysPopup();
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/** Which key is missing, in the sheet's own words — used as the hint under the
|
|
2767
|
+
* field the user is dropped on, so the two surfaces say the same thing. */
|
|
2768
|
+
function keysMissingLine() {
|
|
2769
|
+
const missing = [];
|
|
2770
|
+
if (keyConfigured !== true) missing.push('the AEGIS key');
|
|
2771
|
+
if (providerConfigured !== true) missing.push('your provider (BYOK) key');
|
|
2772
|
+
return missing.join(' and ');
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2593
2775
|
// Assistant text is rendered as sanitized markdown (headings, lists, links,
|
|
2594
2776
|
// highlighted fenced code with a copy button — see renderer/markdown.js);
|
|
2595
2777
|
// user text always stays plain via textContent, and this is the only place
|
|
@@ -3956,6 +4138,12 @@ async function send() {
|
|
|
3956
4138
|
if (!currentSessionId) currentSessionId = newSessionId();
|
|
3957
4139
|
const sessionId = currentSessionId;
|
|
3958
4140
|
pendingSessionId = sessionId;
|
|
4141
|
+
// The two-key sheet yields to the turn it would otherwise sit in front of:
|
|
4142
|
+
// Escape has to reach the interrupt, not a pop-up (see turnInFlight()). This
|
|
4143
|
+
// is the moment a turn begins, so it is the moment to retire one already up —
|
|
4144
|
+
// the gate alone would only stop it REopening, which leaves a sheet opened at
|
|
4145
|
+
// boot covering the whole stream.
|
|
4146
|
+
closeKeysPopup();
|
|
3959
4147
|
|
|
3960
4148
|
// Snapshot prior turns for the model — the new prompt travels separately
|
|
3961
4149
|
// as `prompt` and providers.js appends it after `messages` on the wire.
|
|
@@ -4491,6 +4679,37 @@ async function init() {
|
|
|
4491
4679
|
renderMemoryOverlay();
|
|
4492
4680
|
});
|
|
4493
4681
|
}
|
|
4682
|
+
|
|
4683
|
+
// ── the two-key pop-up ───────────────────────────────────────────────────
|
|
4684
|
+
// Same `?`-guards as the inspector above, for the same reason: the markup is
|
|
4685
|
+
// optional and a missing node must degrade to "no sheet", not a boot crash.
|
|
4686
|
+
// Both "go to" buttons land on the field the key is actually typed into, via
|
|
4687
|
+
// the existing affordances (revealSidebarCard / welcomeByok), so this sheet
|
|
4688
|
+
// adds no second way to store a key — it only says which two exist.
|
|
4689
|
+
if (els.keysClose) els.keysClose.addEventListener('click', closeKeysPopup);
|
|
4690
|
+
if (els.keysBackdrop) els.keysBackdrop.addEventListener('click', closeKeysPopup);
|
|
4691
|
+
if (els.keysDismiss) els.keysDismiss.addEventListener('click', dismissKeysPopup);
|
|
4692
|
+
if (els.keysGotoAegis) {
|
|
4693
|
+
els.keysGotoAegis.addEventListener('click', () => {
|
|
4694
|
+
closeKeysPopup();
|
|
4695
|
+
revealSidebarCard(els.apiKeyInput);
|
|
4696
|
+
if (els.apiKeyInput) els.apiKeyInput.focus();
|
|
4697
|
+
if (els.apiKeyHint) {
|
|
4698
|
+
els.apiKeyHint.textContent =
|
|
4699
|
+
`Missing: ${keysMissingLine()}. This is the AEGIS account key — the same one ` +
|
|
4700
|
+
'the CLI reads from ~/.aegiscode/.env, so pasting it here configures both.';
|
|
4701
|
+
}
|
|
4702
|
+
});
|
|
4703
|
+
}
|
|
4704
|
+
if (els.keysGotoByok) {
|
|
4705
|
+
els.keysGotoByok.addEventListener('click', () => {
|
|
4706
|
+
closeKeysPopup();
|
|
4707
|
+
// Reuses the welcome block's BYOK route rather than repeating it: it
|
|
4708
|
+
// switches the provider class, reveals the Provider settings card and
|
|
4709
|
+
// focuses the first key row — the same landing spot, one implementation.
|
|
4710
|
+
welcomeByok();
|
|
4711
|
+
});
|
|
4712
|
+
}
|
|
4494
4713
|
// ── the autonomous queue card ────────────────────────────────────────────
|
|
4495
4714
|
// The queue half of this file (window.queue -> main.js registerQueueIpc ->
|
|
4496
4715
|
// desktop/lib/local/queue.js + autonomous.js) shipped with every handler
|
|
@@ -4592,6 +4811,14 @@ async function init() {
|
|
|
4592
4811
|
// both are somehow up.
|
|
4593
4812
|
isOverlayOpen: overlayOpen,
|
|
4594
4813
|
onOverlayEscape: () => {
|
|
4814
|
+
// The keys sheet is declared right here in this file, so it is closed
|
|
4815
|
+
// first and needs no handle lookup: while it is up it is the outermost
|
|
4816
|
+
// sheet, and falling through to the memory overlay would close a panel
|
|
4817
|
+
// the user is not looking at.
|
|
4818
|
+
if (keysOverlayOpen()) {
|
|
4819
|
+
closeKeysPopup();
|
|
4820
|
+
return;
|
|
4821
|
+
}
|
|
4595
4822
|
// Same handle, same reason as overlayOpen(): the module has no `close`,
|
|
4596
4823
|
// the mounted panel does. Guessed at the module here too, which threw for
|
|
4597
4824
|
// the same reason — and an overlay can only be closed by its own sheet's
|
package/renderer/index.html
CHANGED
|
@@ -745,6 +745,88 @@
|
|
|
745
745
|
</div>
|
|
746
746
|
</div>
|
|
747
747
|
|
|
748
|
+
<!-- The two-key pop-up. Same sheet as the memory inspector and the φ(α)
|
|
749
|
+
panel (the classes are shared on purpose: one translucent layer, one
|
|
750
|
+
Escape policy), and it exists because the app and the CLI are one engine
|
|
751
|
+
over one store: AEGIS Desktop reads the very `aegis:<...>` account key
|
|
752
|
+
and the very `byok:<provider>` rows `aegiscode` on the command line
|
|
753
|
+
writes, so BOTH keys are what make BOTH surfaces run.
|
|
754
|
+
|
|
755
|
+
Markup is static (no <template>) because it is opened by
|
|
756
|
+
openKeysPopup() from app.js on the status payload, not cloned into the
|
|
757
|
+
transcript. Every id below is declared in app.js's ELEMENT_IDS map and
|
|
758
|
+
toggled by applyKeysPopup(); the unit test slices those functions out of
|
|
759
|
+
app.js and drives them over a fake DOM, so a rename in either place
|
|
760
|
+
fails loudly. Nothing here ever displays a key — only "set" / "not set",
|
|
761
|
+
the same rule the φ(α) panel follows. -->
|
|
762
|
+
<div class="memory-overlay keys-overlay" id="keys-overlay" hidden>
|
|
763
|
+
<div class="memory-backdrop" id="keys-backdrop"></div>
|
|
764
|
+
<div
|
|
765
|
+
class="memory-panel keys-panel"
|
|
766
|
+
role="dialog"
|
|
767
|
+
aria-modal="true"
|
|
768
|
+
aria-labelledby="keys-panel-title"
|
|
769
|
+
>
|
|
770
|
+
<header class="memory-panel-head">
|
|
771
|
+
<h2 id="keys-panel-title">Add both keys — both surfaces run</h2>
|
|
772
|
+
<span class="memory-count" id="keys-count">0 / 2</span>
|
|
773
|
+
<div class="spacer"></div>
|
|
774
|
+
<button type="button" id="keys-close" class="ghost-btn" title="Close (Esc)">
|
|
775
|
+
Close
|
|
776
|
+
</button>
|
|
777
|
+
</header>
|
|
778
|
+
|
|
779
|
+
<p class="hint" id="keys-lead">
|
|
780
|
+
AEGIS Desktop and the <code>aegiscode</code> CLI are the same engine over
|
|
781
|
+
the same store on this disk. Two keys turn on both: the AEGIS account key
|
|
782
|
+
(your bill, and the pooled models) and your own provider key (BYOK). Add
|
|
783
|
+
both and either surface runs — until then, a turn on the missing one is
|
|
784
|
+
refused with the same reason in both places.
|
|
785
|
+
</p>
|
|
786
|
+
|
|
787
|
+
<div class="keys-rows">
|
|
788
|
+
<div class="keys-row" id="keys-row-aegis">
|
|
789
|
+
<span class="keys-state" id="keys-aegis-state">not set</span>
|
|
790
|
+
<div class="keys-body">
|
|
791
|
+
<b>AEGIS key</b> — the account key, written to <code>~/.aegiscode/.env</code>
|
|
792
|
+
(<code>AEGIS_API_KEY</code>) by the CLI's <code>/key</code> and by the
|
|
793
|
+
field in the Status card. Without it both surfaces refuse a send.
|
|
794
|
+
</div>
|
|
795
|
+
<button type="button" id="keys-goto-aegis" class="ghost-btn">
|
|
796
|
+
Paste it here
|
|
797
|
+
</button>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="keys-row" id="keys-row-byok">
|
|
800
|
+
<span class="keys-state" id="keys-byok-state">not set</span>
|
|
801
|
+
<div class="keys-body">
|
|
802
|
+
<b>Your own provider key (BYOK)</b> — a <code>byok:<provider></code>
|
|
803
|
+
row in the shared settings store, written by the CLI's
|
|
804
|
+
<code>/byok-key <provider></code> and by the Provider settings card
|
|
805
|
+
here. It runs the free path on your own account, on both surfaces.
|
|
806
|
+
</div>
|
|
807
|
+
<button type="button" id="keys-goto-byok" class="ghost-btn">Add it here</button>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
|
|
811
|
+
<p class="hint" id="keys-hint"></p>
|
|
812
|
+
|
|
813
|
+
<div class="keys-actions">
|
|
814
|
+
<a
|
|
815
|
+
class="ghost-btn"
|
|
816
|
+
id="keys-get"
|
|
817
|
+
href="https://aegiscloud.org/key?s=desktop"
|
|
818
|
+
target="_blank"
|
|
819
|
+
rel="noreferrer noopener"
|
|
820
|
+
>
|
|
821
|
+
Get a free AEGIS key →
|
|
822
|
+
</a>
|
|
823
|
+
<button type="button" id="keys-dismiss" class="ghost-btn">
|
|
824
|
+
Don't show this again
|
|
825
|
+
</button>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
|
|
748
830
|
<script src="budget.js"></script>
|
|
749
831
|
<script src="usage.js"></script>
|
|
750
832
|
<script src="stream-policy.js"></script>
|
package/renderer/style.css
CHANGED
|
@@ -2443,3 +2443,77 @@ body.is-scrolled .composer {
|
|
|
2443
2443
|
.diff-block .diff-code {
|
|
2444
2444
|
white-space: pre;
|
|
2445
2445
|
}
|
|
2446
|
+
|
|
2447
|
+
/* ---------------------------------------------------------- the keys pop-up
|
|
2448
|
+
The two-key sheet (#keys-overlay, markup in index.html, driven by
|
|
2449
|
+
applyKeysPopup() in app.js). It reuses the memory inspector's translucent
|
|
2450
|
+
layer and panel, so only the row layout is new here.
|
|
2451
|
+
|
|
2452
|
+
The `[hidden]` rule is not decoration: `hidden` is in the markup and the
|
|
2453
|
+
gate turns it on and off, but a class selector on the overlay
|
|
2454
|
+
(.memory-overlay sets a display) outranks the UA stylesheet's
|
|
2455
|
+
`[hidden] { display:none }`, so without this the pop-up would be on screen
|
|
2456
|
+
from the first paint no matter what the gate decided — the exact defect
|
|
2457
|
+
fixed once already for .welcome-connect. */
|
|
2458
|
+
#keys-overlay[hidden] {
|
|
2459
|
+
display: none;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
body.keys-open {
|
|
2463
|
+
overflow: hidden;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
.keys-panel {
|
|
2467
|
+
max-width: 720px;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
.keys-rows {
|
|
2471
|
+
display: flex;
|
|
2472
|
+
flex-direction: column;
|
|
2473
|
+
gap: 10px;
|
|
2474
|
+
/* padding, not flex-grow: the panel already scrolls (memory-panel). */
|
|
2475
|
+
padding: 4px 0;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
.keys-row {
|
|
2479
|
+
display: grid;
|
|
2480
|
+
grid-template-columns: 78px 1fr auto;
|
|
2481
|
+
align-items: center;
|
|
2482
|
+
gap: 10px;
|
|
2483
|
+
padding: 10px 12px;
|
|
2484
|
+
border: 1px solid var(--line, #2a2f3a);
|
|
2485
|
+
border-radius: 10px;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
/* A row whose key is already on the machine reads as done and drops back, so
|
|
2489
|
+
the one still missing is the one the eye lands on. The state line under the
|
|
2490
|
+
title counts the same two facts, so they cannot disagree. */
|
|
2491
|
+
.keys-row.set {
|
|
2492
|
+
opacity: 0.7;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
.keys-state {
|
|
2496
|
+
font-family: var(--font-mono);
|
|
2497
|
+
font-size: 12px;
|
|
2498
|
+
text-align: center;
|
|
2499
|
+
padding: 2px 6px;
|
|
2500
|
+
border-radius: 6px;
|
|
2501
|
+
color: #9aa4b2;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
.keys-row.set .keys-state {
|
|
2505
|
+
color: #7ee0a0;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
.keys-body {
|
|
2509
|
+
font-size: 13px;
|
|
2510
|
+
line-height: 1.45;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
.keys-actions {
|
|
2514
|
+
display: flex;
|
|
2515
|
+
align-items: center;
|
|
2516
|
+
gap: 10px;
|
|
2517
|
+
flex-wrap: wrap;
|
|
2518
|
+
padding-top: 6px;
|
|
2519
|
+
}
|
package/vendor/credentials.js
CHANGED
|
@@ -18,10 +18,18 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Resolution order, first hit wins:
|
|
20
20
|
*
|
|
21
|
-
* 1. `AEGIS_API_KEY`
|
|
22
|
-
*
|
|
23
|
-
* 2. `credentials.json` in the data dir, mode 0600. The writable store
|
|
24
|
-
*
|
|
21
|
+
* 1. `AEGIS_API_KEY` **exported by the launching shell** — so CI and an
|
|
22
|
+
* explicit export keep working.
|
|
23
|
+
* 2. `credentials.json` in the data dir, mode 0600. The writable store, and
|
|
24
|
+
* the record of a key the user deliberately saved (`aegiscode login`, the
|
|
25
|
+
* CLI's `/key`, the desktop's Settings pane).
|
|
26
|
+
* 3. `AEGIS_API_KEY` from `~/.aegiscode/.env` — the documented place to put a
|
|
27
|
+
* key, but a FALLBACK rather than a decision: no save stamp, shared by
|
|
28
|
+
* every host, hand-edited. Both rungs read the same variable, which is why
|
|
29
|
+
* `isFromEnvFile()` exists — without it a leftover line in that file
|
|
30
|
+
* outranked rung 2 forever and a freshly saved key was silently ignored
|
|
31
|
+
* while every host reported it as saved. See `resolveApiKey`.
|
|
32
|
+
* 4. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
|
|
25
33
|
* earlier AEGIS CLI left in the same data dir. Read, never written, and
|
|
26
34
|
* never deleted: it is another product's file and the user's key is in
|
|
27
35
|
* it. When one is found it is *also* copied into credentials.json so the
|
|
@@ -189,21 +197,79 @@ function adoptLegacy(dir) {
|
|
|
189
197
|
return { adopted: Object.keys(patch) };
|
|
190
198
|
}
|
|
191
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Was this environment value put there by `~/.aegiscode/.env`, rather than
|
|
202
|
+
* exported by the launching shell?
|
|
203
|
+
*
|
|
204
|
+
* The env object is passed through: provenance is a property of the object the
|
|
205
|
+
* value was loaded INTO, so a caller resolving from an injected `{ env }` (every
|
|
206
|
+
* test, and any host that scopes its environment) must be answered about that
|
|
207
|
+
* object rather than about `process.env`.
|
|
208
|
+
*
|
|
209
|
+
* Loaded lazily and defensively: this module is deliberately dependency-free
|
|
210
|
+
* (it is copied into hosts that bundle no other file), so an absent env-file
|
|
211
|
+
* module simply means "nothing is file-sourced" — which is the pre-existing
|
|
212
|
+
* behaviour, not a failure.
|
|
213
|
+
*/
|
|
214
|
+
function fromEnvFile(name, value, env) {
|
|
215
|
+
try {
|
|
216
|
+
const envFile = require('./env-file.js');
|
|
217
|
+
return (
|
|
218
|
+
typeof envFile.isFromEnvFile === 'function' &&
|
|
219
|
+
envFile.isFromEnvFile(name, value, env ? { env } : undefined)
|
|
220
|
+
);
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
/**
|
|
193
227
|
* The key to use, and where it came from.
|
|
194
228
|
*
|
|
195
|
-
*
|
|
229
|
+
* Order, and the one asymmetry that matters:
|
|
230
|
+
*
|
|
231
|
+
* 1. `AEGIS_API_KEY` **exported by the launching shell** — unchanged, and
|
|
232
|
+
* still first, so CI and an explicit `AEGIS_API_KEY=… aegiscode` keep
|
|
233
|
+
* working.
|
|
234
|
+
* 2. `credentials.json` — a key the user explicitly saved with
|
|
235
|
+
* `aegiscode login`, the CLI's `/key`, or the desktop's Settings pane.
|
|
236
|
+
* 3. `AEGIS_API_KEY` **from `~/.aegiscode/.env`** — the documented place to
|
|
237
|
+
* put a key, but a fallback rather than a decision: it carries no save
|
|
238
|
+
* stamp and the file is shared by every host and hand-edited by users.
|
|
239
|
+
* 4. `config.json`'s legacy `aegiscloud.api_key`.
|
|
240
|
+
*
|
|
241
|
+
* Rungs 1 and 3 are the same variable, which is exactly the bug this ordering
|
|
242
|
+
* fixes. `env-file.js` copies the file into `process.env` at host start-up, so a
|
|
243
|
+
* leftover line — a rotated key that was never cleaned up, a value from an
|
|
244
|
+
* older install, a provider row mirrored into the wrong name — became
|
|
245
|
+
* indistinguishable from a deliberate export and permanently outranked the file
|
|
246
|
+
* the user had just written. The symptom is the one that gets reported as
|
|
247
|
+
* "it says the key is saved but it is not using it": `aegiscode login` prints
|
|
248
|
+
* `key saved`, the desktop prints `saved (aegis_••••1234)`, `key status` reads
|
|
249
|
+
* back the new key — and every request authenticates with the stale one, so the
|
|
250
|
+
* account check 401s and nothing names the cause. `env-file.js`'s own contract
|
|
251
|
+
* is that the file is "a convenience for the common case, not a way for a stale
|
|
252
|
+
* file to shadow an explicit" setting; `isFromEnvFile()` is what makes that
|
|
253
|
+
* true for a save as well as for an export.
|
|
254
|
+
*
|
|
255
|
+
* @returns {{key:string, source:'env'|'credentials'|'envfile'|'config'|'none', from:string}}
|
|
196
256
|
*/
|
|
197
257
|
function resolveApiKey(o = {}) {
|
|
198
258
|
const env = o.env || process.env;
|
|
199
259
|
const dir = o.dir;
|
|
200
260
|
const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
|
|
201
|
-
|
|
261
|
+
// An explicit export wins outright. A value the env FILE supplied does not:
|
|
262
|
+
// it is held back to rung 3 so an explicit save outranks it.
|
|
263
|
+
if (fromEnv && !fromEnvFile(KEY_ENV, env && env[KEY_ENV], env)) {
|
|
264
|
+
return { key: fromEnv, source: 'env', from: KEY_ENV };
|
|
265
|
+
}
|
|
202
266
|
|
|
203
267
|
const creds = readCredentials(dir);
|
|
204
268
|
const stored = normalizeApiKey(creds.aegisApiKey);
|
|
205
269
|
if (stored) return { key: stored, source: 'credentials', from: credentialsPath(dir) };
|
|
206
270
|
|
|
271
|
+
if (fromEnv) return { key: fromEnv, source: 'envfile', from: envFileLabel(env, dir) };
|
|
272
|
+
|
|
207
273
|
const legacy = readLegacyConfig(dir).apiKey;
|
|
208
274
|
if (legacy) return { key: legacy, source: 'config', from: configPath(dir) };
|
|
209
275
|
|
|
@@ -318,10 +384,30 @@ function clientOptions(o = {}) {
|
|
|
318
384
|
const SOURCE_LABEL = {
|
|
319
385
|
env: `${KEY_ENV}`,
|
|
320
386
|
credentials: 'saved key file',
|
|
387
|
+
envfile: `~/.aegiscode/.env (${KEY_ENV})`,
|
|
321
388
|
config: 'config.json (aegis CLI)',
|
|
322
389
|
none: 'not set',
|
|
323
390
|
};
|
|
324
391
|
|
|
392
|
+
/**
|
|
393
|
+
* The env file a value came from, for the `envfile` status line. Resolved
|
|
394
|
+
* through env-file.js when it is present so the answer is the real path
|
|
395
|
+
* (`$AEGISCODE_HOME/.env` included) rather than a guess.
|
|
396
|
+
*/
|
|
397
|
+
function envFileLabel(env, dir) {
|
|
398
|
+
try {
|
|
399
|
+
const envFile = require('./env-file.js');
|
|
400
|
+
if (typeof envFile.envFileFor === 'function') return envFile.envFileFor(dir);
|
|
401
|
+
} catch {}
|
|
402
|
+
return envFileForFallback(dir);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** `~/.aegiscode/.env`, built here so the label survives a host that ships no
|
|
406
|
+
* env-file module (same rule aegisHome() exists for). */
|
|
407
|
+
function envFileForFallback(dir) {
|
|
408
|
+
return path.join(dir || aegisHome(), '.env');
|
|
409
|
+
}
|
|
410
|
+
|
|
325
411
|
function sourceLabel(source) {
|
|
326
412
|
return SOURCE_LABEL[source] || SOURCE_LABEL.none;
|
|
327
413
|
}
|
|
@@ -377,6 +463,8 @@ module.exports = {
|
|
|
377
463
|
adoptLegacy,
|
|
378
464
|
resolveApiKey,
|
|
379
465
|
hasApiKey,
|
|
466
|
+
fromEnvFile,
|
|
467
|
+
envFileForFallback,
|
|
380
468
|
saveApiKey,
|
|
381
469
|
clearApiKey,
|
|
382
470
|
resolveMemoryToken,
|
package/vendor/env-file.js
CHANGED
|
@@ -262,9 +262,67 @@ function parseEnvText(text) {
|
|
|
262
262
|
* `loaded` — names taken from the file. `kept` — names the environment
|
|
263
263
|
* already had, left alone (a real export always wins).
|
|
264
264
|
*/
|
|
265
|
+
/**
|
|
266
|
+
* Provenance of every value this process took from `~/.aegiscode/.env`.
|
|
267
|
+
*
|
|
268
|
+
* Keyed off the env object itself (a WeakMap, so an injected `{ env }` in a
|
|
269
|
+
* test is tracked without leaking and `process.env` needs no extra property).
|
|
270
|
+
* See `isFromEnvFile`.
|
|
271
|
+
*/
|
|
272
|
+
const ENV_SOURCED = new WeakMap();
|
|
273
|
+
|
|
274
|
+
function sourcedFrom(env) {
|
|
275
|
+
let map = ENV_SOURCED.get(env);
|
|
276
|
+
if (!map) {
|
|
277
|
+
map = new Map();
|
|
278
|
+
ENV_SOURCED.set(env, map);
|
|
279
|
+
}
|
|
280
|
+
return map;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Was `name`'s CURRENT value in `env` one this process took from the shared env
|
|
285
|
+
* file, rather than something the launching shell exported?
|
|
286
|
+
*
|
|
287
|
+
* This is the distinction the file's own rule 1 depends on and could not
|
|
288
|
+
* previously express. The file is documented as a *convenience for the common
|
|
289
|
+
* case, not a way for a stale file to shadow an explicit export* — but once a
|
|
290
|
+
* value is copied into `process.env` the two are the same thing, so every
|
|
291
|
+
* env-first reader (`client/credentials.js`) treated a line in the file exactly
|
|
292
|
+
* like a deliberate export and let it win forever. Concretely: a user runs
|
|
293
|
+
* `aegiscode login`, the key is written to `credentials.json` (0600) and
|
|
294
|
+
* reported as saved, and every request since then authenticates with whatever
|
|
295
|
+
* `AEGIS_API_KEY=` line the file still carries — the "it says saved but the key
|
|
296
|
+
* is wrong" report. A real export still wins; a file value is a fallback.
|
|
297
|
+
*
|
|
298
|
+
* `value` is checked as well as the name: a shell export that replaced the
|
|
299
|
+
* loaded value is no longer file-sourced, and neither is a later `setEnvValue`
|
|
300
|
+
* (which applies the value it just wrote and marks it accordingly).
|
|
301
|
+
*
|
|
302
|
+
* @param {string} name
|
|
303
|
+
* @param {string} [value] the current value, to confirm it is still the one the
|
|
304
|
+
* file provided. Omit to ask about the name alone.
|
|
305
|
+
* @param {object} [o] `env`, for tests and injected environments
|
|
306
|
+
*/
|
|
307
|
+
function isFromEnvFile(name, value, o = {}) {
|
|
308
|
+
const env = o.env || process.env;
|
|
309
|
+
const map = ENV_SOURCED.get(env);
|
|
310
|
+
if (!map || !map.has(name)) return false;
|
|
311
|
+
if (value === undefined) return true;
|
|
312
|
+
return map.get(name) === String(value);
|
|
313
|
+
}
|
|
314
|
+
|
|
265
315
|
function loadEnvFile(o = {}) {
|
|
266
316
|
const env = o.env || process.env;
|
|
267
317
|
const file = o.file || envFileFor(o.dir);
|
|
318
|
+
// Names this process took from the FILE, with the value it took. Kept so a
|
|
319
|
+
// consumer can tell the two sources apart: rule 1 above makes the file a
|
|
320
|
+
// fallback, but the moment its value lands in `process.env` it is otherwise
|
|
321
|
+
// indistinguishable from an explicit `AEGIS_API_KEY=… aegiscode` export, and
|
|
322
|
+
// `client/credentials.js`'s resolution order is env-first. Without this
|
|
323
|
+
// marker a leftover line in a file every host sources at start-up outranks
|
|
324
|
+
// the key the user explicitly saved — see `isFromEnvFile()` below.
|
|
325
|
+
const sourced = sourcedFrom(env);
|
|
268
326
|
const result = {
|
|
269
327
|
ok: false, file, loaded: [], kept: [], refused: [], reason: '', loose: false, mode: 0,
|
|
270
328
|
};
|
|
@@ -296,8 +354,14 @@ function loadEnvFile(o = {}) {
|
|
|
296
354
|
}
|
|
297
355
|
if (env[name] === undefined || env[name] === '') {
|
|
298
356
|
env[name] = value;
|
|
357
|
+
// Provenance: this value is the FILE's, not the launching shell's. An
|
|
358
|
+
// env-first reader must treat it as the fallback the file is documented
|
|
359
|
+
// to be, not as an explicit export that outranks a saved key.
|
|
360
|
+
sourced.set(name, value);
|
|
299
361
|
result.loaded.push(name);
|
|
300
362
|
} else {
|
|
363
|
+
// A real export wins (rule 1) and is deliberately NOT marked: it is the
|
|
364
|
+
// one source allowed to outrank `credentials.json`.
|
|
301
365
|
result.kept.push(name);
|
|
302
366
|
}
|
|
303
367
|
}
|
|
@@ -615,7 +679,12 @@ function setEnvValue(name, value, o = {}) {
|
|
|
615
679
|
out.changed = false;
|
|
616
680
|
out.replaced = true;
|
|
617
681
|
out.mode = previousMode || SECRET_FILE_MODE;
|
|
618
|
-
if (o.apply !== false)
|
|
682
|
+
if (o.apply !== false) {
|
|
683
|
+
env[name] = secret;
|
|
684
|
+
// Applied, and the file carries it: file-sourced, so an env-first reader
|
|
685
|
+
// treats it as the fallback it is (see isFromEnvFile).
|
|
686
|
+
sourcedFrom(env).set(name, secret);
|
|
687
|
+
}
|
|
619
688
|
return out;
|
|
620
689
|
}
|
|
621
690
|
|
|
@@ -640,7 +709,11 @@ function setEnvValue(name, value, o = {}) {
|
|
|
640
709
|
out.replaced = replaced;
|
|
641
710
|
out.mode = SECRET_FILE_MODE;
|
|
642
711
|
out.tightened = !out.created && previousMode !== 0 && (previousMode & 0o077) !== 0;
|
|
643
|
-
if (o.apply !== false)
|
|
712
|
+
if (o.apply !== false) {
|
|
713
|
+
env[name] = secret;
|
|
714
|
+
// Just written to the file and applied here: file-sourced (see above).
|
|
715
|
+
sourcedFrom(env).set(name, secret);
|
|
716
|
+
}
|
|
644
717
|
return out;
|
|
645
718
|
}
|
|
646
719
|
|
|
@@ -810,16 +883,91 @@ function keyStatus(name, o = {}) {
|
|
|
810
883
|
*
|
|
811
884
|
* @param {string} modelId
|
|
812
885
|
* @param {string} [cls] 'byok' | 'aegis' | anything else
|
|
813
|
-
* @returns {{env:string, provider:string, pooled:boolean}}
|
|
886
|
+
* @returns {{env:string, provider:string, vendor:string, pooled:boolean}}
|
|
814
887
|
*/
|
|
815
888
|
function keyForModelId(modelId, cls) {
|
|
816
889
|
const id = String(modelId == null ? '' : modelId).trim();
|
|
817
890
|
const byok = String(cls || '').toLowerCase() === 'byok' || id.includes(':');
|
|
818
891
|
if (byok && id.includes(':')) {
|
|
819
892
|
const provider = id.slice(0, id.indexOf(':')).trim();
|
|
820
|
-
return { env: envVarFor(provider), provider, pooled: false };
|
|
893
|
+
return { env: envVarFor(provider), provider, vendor: provider, pooled: false };
|
|
894
|
+
}
|
|
895
|
+
// A pooled id is served by the pool, so the credential the ROUTE needs is the
|
|
896
|
+
// account key — but the id may still NAME the vendor underneath it
|
|
897
|
+
// (`deepseek`, `anthropic-haiku`, `openai-gpt4o-mini`). That vendor is what
|
|
898
|
+
// `vendor` reports, and it is not the same question as `env`: it is the
|
|
899
|
+
// provider key a user may also want on this machine, which the picker asks
|
|
900
|
+
// for after the account key. `provider` stays '' for pooled on purpose — the
|
|
901
|
+
// byok call path reads it as "which provider's settings row to address", and
|
|
902
|
+
// a pooled row has none.
|
|
903
|
+
return { env: ACCOUNT_ENV_VAR, provider: '', vendor: providerForModelId(id, cls), pooled: true };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Vendors whose slug is a namespace a model id may be spelled under.
|
|
908
|
+
*
|
|
909
|
+
* Deliberately a *table* and not a pattern: the derivation below is a claim that
|
|
910
|
+
* a key for this vendor exists, so an id whose first segment is not on this list
|
|
911
|
+
* derives NO vendor (and the picker asks only for the account key) rather than
|
|
912
|
+
* inventing a variable name like `NEXUS_API_KEY` for the pool's own namespace.
|
|
913
|
+
* The list is the platform's providers as its own BYOK catalog spells them
|
|
914
|
+
* (aegis1 services/nexus_provider/catalog.py MODEL_CATALOG) plus the ids this
|
|
915
|
+
* module already re-spells in ALIASES above — keep it in step with that catalog,
|
|
916
|
+
* not with whatever vendor a user might mention.
|
|
917
|
+
*/
|
|
918
|
+
const VENDOR_SLUGS = Object.freeze([
|
|
919
|
+
'openai',
|
|
920
|
+
'anthropic',
|
|
921
|
+
'deepseek',
|
|
922
|
+
'google',
|
|
923
|
+
'gemini',
|
|
924
|
+
'groq',
|
|
925
|
+
'mistral',
|
|
926
|
+
'openrouter',
|
|
927
|
+
'together',
|
|
928
|
+
'together-ai',
|
|
929
|
+
'xai',
|
|
930
|
+
'x-ai',
|
|
931
|
+
'grok',
|
|
932
|
+
'huggingface',
|
|
933
|
+
'fireworks',
|
|
934
|
+
'voyage-ai',
|
|
935
|
+
'azure-openai',
|
|
936
|
+
]);
|
|
937
|
+
|
|
938
|
+
/** The multi-segment slugs, longest first, so `azure-openai-x` matches the full slug. */
|
|
939
|
+
const VENDOR_COMPOUNDS = Object.freeze(
|
|
940
|
+
VENDOR_SLUGS.filter((s) => s.includes('-')).sort((a, b) => b.length - a.length)
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* The vendor a model id names, or '' — `deepseek-flash` → `deepseek`,
|
|
945
|
+
* `nexus-brain` → '', `deepseek:deepseek-v4-flash` → `deepseek`.
|
|
946
|
+
*
|
|
947
|
+
* The one question the picker needs to answer "is there a SECOND key worth
|
|
948
|
+
* asking for on this row?" A pooled row is billed to the AEGIS account, so its
|
|
949
|
+
* route needs the account key and nothing else — but when the row is named
|
|
950
|
+
* after a vendor whose key this machine could hold (`deepseek`), asking for that
|
|
951
|
+
* key too is what lets the account actually call the model instead of the pool
|
|
952
|
+
* failing a turn with "no provider key". That is the gap this closes:
|
|
953
|
+
* aegis-key present, models listed, and every turn still failing because the
|
|
954
|
+
* vendor key the models route through was never collected.
|
|
955
|
+
*
|
|
956
|
+
* @param {string} modelId
|
|
957
|
+
* @param {string} [cls] unused by the derivation; accepted so a caller can pass
|
|
958
|
+
* the same (id, cls) pair it passes `keyForModelId` without branching.
|
|
959
|
+
* @returns {string} the vendor slug, or '' when the id names none this module
|
|
960
|
+
* knows a variable for.
|
|
961
|
+
*/
|
|
962
|
+
function providerForModelId(modelId, cls) { // eslint-disable-line no-unused-vars
|
|
963
|
+
const id = String(modelId == null ? '' : modelId).trim().toLowerCase();
|
|
964
|
+
if (!id) return '';
|
|
965
|
+
if (id.includes(':')) return id.slice(0, id.indexOf(':')).trim();
|
|
966
|
+
for (const slug of VENDOR_COMPOUNDS) {
|
|
967
|
+
if (id === slug || id.startsWith(slug + '-')) return slug;
|
|
821
968
|
}
|
|
822
|
-
|
|
969
|
+
const first = id.split('-')[0];
|
|
970
|
+
return VENDOR_SLUGS.includes(first) ? first : '';
|
|
823
971
|
}
|
|
824
972
|
|
|
825
973
|
module.exports = {
|
|
@@ -831,7 +979,15 @@ module.exports = {
|
|
|
831
979
|
envVarFor,
|
|
832
980
|
parseEnvText,
|
|
833
981
|
loadEnvFile,
|
|
982
|
+
// Provenance of what loadEnvFile put in the environment: a value taken from
|
|
983
|
+
// the FILE is a fallback, not an explicit export, and the env-first readers
|
|
984
|
+
// (client/credentials.js) must be able to tell the two apart.
|
|
985
|
+
isFromEnvFile,
|
|
834
986
|
providerKeyFromEnv,
|
|
987
|
+
// The vendor half of "what key does this model id need": the account key is
|
|
988
|
+
// the route's credential, this is the provider key underneath it.
|
|
989
|
+
providerForModelId,
|
|
990
|
+
VENDOR_SLUGS,
|
|
835
991
|
isWritableName,
|
|
836
992
|
quoteEnvValue,
|
|
837
993
|
upsertEnvText,
|