aegis-desktop 0.8.9 → 0.8.10
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/avatar/turn.js +1 -1
- package/lib/local/autonomous.js +2 -2
- package/lib/local/engine.js +64 -18
- package/lib/provider-key-mirror.js +136 -0
- package/main.js +29 -0
- package/package.json +1 -1
- package/renderer/app.js +40 -1
- package/vendor/brain-catalog.js +75 -0
- package/vendor/byok-catalog.js +144 -0
- package/vendor/env-file.js +398 -11
package/lib/avatar/turn.js
CHANGED
|
@@ -107,7 +107,7 @@ function recallPolicy(caps, opts = {}) {
|
|
|
107
107
|
* - the pooled brain tier (`nexus-brain` and the `-smart`/`-neo` spellings the
|
|
108
108
|
* server serves as aliases), which is not one model at all: the server fans
|
|
109
109
|
* the turn out to reasoning workers plus a synthesis pass. That is the one
|
|
110
|
-
* id the Aegis Cloud class offers (engine.js
|
|
110
|
+
* id the Aegis Cloud class offers (engine.js offerableCatalog), so an
|
|
111
111
|
* Aegis Cloud turn satisfies the floor by construction.
|
|
112
112
|
*/
|
|
113
113
|
const REASONING_MODEL_RE = /^(?:deepseek-(?:v4(?:\.\d+)?-(?:flash|pro)|flash|pro|reasoner)|(?:nexus|aegis)-brain(?:-(?:smart|neo))?)$/;
|
package/lib/local/autonomous.js
CHANGED
|
@@ -84,7 +84,7 @@ function writtenPath(tool) {
|
|
|
84
84
|
* The default AEGIS Cloud model for autonomous work: the pooled brain, which
|
|
85
85
|
* is the tier the server can fan out to multiple reasoning workers and
|
|
86
86
|
* synthesise. `nexus-brain` is the canonical id the catalog itself prefers
|
|
87
|
-
* (the other tier spellings are aliases of it — see
|
|
87
|
+
* (the other tier spellings are aliases of it — see offerableCatalog in
|
|
88
88
|
* engine.js).
|
|
89
89
|
*
|
|
90
90
|
* The model id is only the tier. It is NOT what makes an autonomous task
|
|
@@ -108,7 +108,7 @@ const DEFAULT_MODEL = 'nexus-brain';
|
|
|
108
108
|
* the pool auto-route across whichever providers hold a live key.
|
|
109
109
|
*
|
|
110
110
|
* The accept-list is therefore the pooled-brain tier family — the one entry
|
|
111
|
-
* engine.js's
|
|
111
|
+
* engine.js's offerableCatalog offers for the Aegis Cloud class, plus the
|
|
112
112
|
* `-smart`/`-neo` tier spellings the server still serves as aliases of it. This
|
|
113
113
|
* mirrors selectBrainEntry() there rather than re-deriving "anything starting
|
|
114
114
|
* with nexus-": `nexus-fast` is not a tier the catalog has ever served, and
|
package/lib/local/engine.js
CHANGED
|
@@ -104,6 +104,35 @@ function requireSharedBrain() {
|
|
|
104
104
|
throw last;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The shared BYOK model additions (`client/byok-catalog.js`), resolved exactly
|
|
109
|
+
* the two ways above and for the same reason: the CLI vendors THIS file
|
|
110
|
+
* (cli/scripts/predist.mjs) and requires the same module through
|
|
111
|
+
* `cli/src/sharedpaths.js`, so a copy-per-host here is how the terminal and the
|
|
112
|
+
* window would come to offer one account two different provider model lists —
|
|
113
|
+
* the defect class this repo keeps paying for.
|
|
114
|
+
*
|
|
115
|
+
* The module is pure and returns a NEW array, so a caller may apply it to a
|
|
116
|
+
* payload it did not build. It never invents a provider: a provider absent from
|
|
117
|
+
* the server's catalog stays absent, because a row that accepts a vendor key
|
|
118
|
+
* must correspond to a slug the relay actually takes.
|
|
119
|
+
*/
|
|
120
|
+
function requireSharedByokCatalog() {
|
|
121
|
+
const candidates = [
|
|
122
|
+
() => require('../../../client/byok-catalog.js'),
|
|
123
|
+
() => require('../../vendor/byok-catalog.js'),
|
|
124
|
+
];
|
|
125
|
+
let last = null;
|
|
126
|
+
for (const load of candidates) {
|
|
127
|
+
try {
|
|
128
|
+
return load();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
last = err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw last;
|
|
134
|
+
}
|
|
135
|
+
|
|
107
136
|
/**
|
|
108
137
|
* How long a turn waits for the working-tree lock before running anyway.
|
|
109
138
|
*
|
|
@@ -272,27 +301,37 @@ function normalizeCatalog(models) {
|
|
|
272
301
|
}
|
|
273
302
|
|
|
274
303
|
/**
|
|
275
|
-
* The Aegis Cloud
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
304
|
+
* The Aegis Cloud (pooled) dropdown offers Nexus, and only Nexus.
|
|
305
|
+
*
|
|
306
|
+
* The catalog (`/api/v1/models`) lists per-provider ids (`deepseek`,
|
|
307
|
+
* `anthropic`, `groq`, `openai`, ...) alongside five pooled-brain tier
|
|
308
|
+
* spellings (`{aegis,nexus}-brain[-smart|-neo]`) that all route the same
|
|
309
|
+
* worker pool. This host briefly offered the full list — the per-provider ids
|
|
310
|
+
* included — on the theory that a funded account should be able to pin the
|
|
311
|
+
* exact model it pays for. Niklas's explicit instruction is narrower: AEGIS
|
|
312
|
+
* Cloud offers Nexus only, no per-provider pin, so this is back on
|
|
313
|
+
* `filterAegisCatalog` — the shared rule's single-row collapse.
|
|
285
314
|
*
|
|
286
|
-
* The
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
315
|
+
* The rule itself lives in `client/brain-catalog.js`, shared with the CLI, so
|
|
316
|
+
* there is exactly one copy of it: two copies is how this host and the
|
|
317
|
+
* terminal previously came to offer the same account two different model
|
|
318
|
+
* lists. `filterAegisCatalog` collapses the five brain-tier spellings to the
|
|
319
|
+
* one row labelled Nexus; a payload with no brain tier (the BYOK listing) is
|
|
320
|
+
* unaffected — see the desktop's byok branch below, which still offers every
|
|
321
|
+
* provider id the account holds a key for. Resolved the two ways this repo
|
|
322
|
+
* resolves every shared module — repo-relative in a checkout, and this app's
|
|
323
|
+
* staged vendor/ tree.
|
|
293
324
|
*/
|
|
294
325
|
const { filterAegisCatalog } = requireSharedBrain();
|
|
295
326
|
|
|
327
|
+
/**
|
|
328
|
+
* The BYOK additions, applied to the server's provider catalog below. Same
|
|
329
|
+
* one-copy rule as the brain rule above: the terminal requires this same module
|
|
330
|
+
* (cli/src/commands.js, through sharedpaths.js), so neither host can offer a
|
|
331
|
+
* provider row the other does not.
|
|
332
|
+
*/
|
|
333
|
+
const { withByokAdditions } = requireSharedByokCatalog();
|
|
334
|
+
|
|
296
335
|
// ── Agent-loop helpers ──────────────────────────────────────────────────────
|
|
297
336
|
|
|
298
337
|
/** Parse a model-supplied argument blob (string or already-parsed object). */
|
|
@@ -796,7 +835,14 @@ function createLocalEngine({
|
|
|
796
835
|
let fee = null;
|
|
797
836
|
try {
|
|
798
837
|
const data = await aegis.byokProviders();
|
|
799
|
-
|
|
838
|
+
// The server's catalog, plus the ids it does not name but the vendor
|
|
839
|
+
// serves and the relay will forward (`client/byok-catalog.js`, shared
|
|
840
|
+
// with the CLI so both hosts offer the same rows). Applied HERE, at the
|
|
841
|
+
// single point where the payload enters this engine, so every consumer
|
|
842
|
+
// of `providers` — the model list built just below, the renderer's
|
|
843
|
+
// per-provider Settings rows, and the CLI's own `/byok` listing when it
|
|
844
|
+
// runs on this engine — sees one list instead of three opinions.
|
|
845
|
+
providers = withByokAdditions((data && data.providers) || []);
|
|
800
846
|
// The handling fee AEGIS adds on top of the caller's vendor bill. It is
|
|
801
847
|
// the server's own published rate (services/pricing.price_byok_call) and
|
|
802
848
|
// is passed through untouched — never re-derived here, because a client
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* provider-key-mirror.js — make a provider key saved in the DESKTOP visible to
|
|
5
|
+
* every other host on the same machine.
|
|
6
|
+
*
|
|
7
|
+
* The desktop's Provider settings rows persist into its own encrypted settings
|
|
8
|
+
* store (`desktop/lib/settings.js` -> `settings.json` under the app's userData
|
|
9
|
+
* dir). That store is private to this app: `aegiscode` (the CLI) and the MCP
|
|
10
|
+
* plugin never read it, and they cannot — it is an encrypted Electron store in
|
|
11
|
+
* another process's data directory. So a key added in the desktop was invisible
|
|
12
|
+
* to the CLI on the next run: same machine, same provider, key asked for twice.
|
|
13
|
+
*
|
|
14
|
+
* The fix is not to teach every host to open an encrypted Electron store. It is
|
|
15
|
+
* to write the key where all four hosts already look at start-up —
|
|
16
|
+
* `~/.aegiscode/.env`, through the ONE shared writer (`client/env-file.js`
|
|
17
|
+
* `setEnvValue`). After this, "add your provider key in the app" and "put it in
|
|
18
|
+
* ~/.aegiscode/.env" are the same instruction, which is the point.
|
|
19
|
+
*
|
|
20
|
+
* Policy, deliberately narrow:
|
|
21
|
+
*
|
|
22
|
+
* - PROVIDER keys only. The AEGIS *account* key is already shared through
|
|
23
|
+
* `credentials.json` (`client/credentials.js`), which the CLI, the MCP
|
|
24
|
+
* plugin and the desktop all read. A second copy in the env file would be a
|
|
25
|
+
* second authority that can silently disagree with the first — exactly the
|
|
26
|
+
* "which one won?" failure env-file.js refuses for duplicate lines. So
|
|
27
|
+
* `setAegisKey()` is deliberately NOT wrapped.
|
|
28
|
+
* - The variable name comes from `envFile.envVarFor(provider)`, the same
|
|
29
|
+
* mapping the CLI's model picker uses (`keyForModelId`), and must end in
|
|
30
|
+
* `_API_KEY`. A name we cannot map is a name we do not invent: an
|
|
31
|
+
* unrecognised provider leaves the file untouched rather than growing an
|
|
32
|
+
* unrelated variable.
|
|
33
|
+
* - Removing a key only touches the file if the file actually carries that
|
|
34
|
+
* variable, so "Remove" on a provider the file never knew about does not
|
|
35
|
+
* add a stray `OPENAI_API_KEY=` line to a hand-written file.
|
|
36
|
+
* - A failure to mirror is REPORTED and swallowed. The key IS saved (the
|
|
37
|
+
* encrypted store has it); only the cross-host copy failed, and refusing
|
|
38
|
+
* the whole save over that would throw away the key the user just typed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Reserved namespaces (`__aegis`, `__quickLauncher`, …) are the store's own
|
|
42
|
+
* bookkeeping, not providers — never a key variable. */
|
|
43
|
+
const RESERVED_PREFIX = '__';
|
|
44
|
+
|
|
45
|
+
/** Every provider key variable in this product family ends this way. */
|
|
46
|
+
const KEY_VAR_SUFFIX = /_API_KEY$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write one provider key into the shared env file.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} envFile client/env-file.js (injected: this module must stay
|
|
52
|
+
* unit-testable without touching the real home dir)
|
|
53
|
+
* @param {string} provider provider id, e.g. 'openai'
|
|
54
|
+
* @param {string} key the key; '' means "clear it"
|
|
55
|
+
* @param {object} [opts] `onWarn(message)`, `envOpts` forwarded to env-file
|
|
56
|
+
* (its `{ file, env }` seam, used by tests)
|
|
57
|
+
* @returns {object|null} env-file's own result, or null when nothing was
|
|
58
|
+
* mirrored (no mapping, reserved row, absent variable)
|
|
59
|
+
*/
|
|
60
|
+
function mirrorProviderKeyToEnvFile(envFile, provider, key, opts = {}) {
|
|
61
|
+
const warn = typeof opts.onWarn === 'function' ? opts.onWarn : () => {};
|
|
62
|
+
const envOpts = opts.envOpts || {};
|
|
63
|
+
if (!envFile || typeof envFile.setEnvValue !== 'function') return null;
|
|
64
|
+
if (typeof provider !== 'string' || !provider) return null;
|
|
65
|
+
if (provider.startsWith(RESERVED_PREFIX)) return null;
|
|
66
|
+
|
|
67
|
+
const name = typeof envFile.envVarFor === 'function' ? envFile.envVarFor(provider) : '';
|
|
68
|
+
if (!name || !KEY_VAR_SUFFIX.test(name)) return null;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (key) return envFile.setEnvValue(name, key, envOpts);
|
|
72
|
+
const status =
|
|
73
|
+
typeof envFile.keyStatus === 'function' ? envFile.keyStatus(name, envOpts) : null;
|
|
74
|
+
if (!status || !status.inFile) return null;
|
|
75
|
+
if (typeof envFile.clearEnvValue === 'function') {
|
|
76
|
+
return envFile.clearEnvValue(name, envOpts);
|
|
77
|
+
}
|
|
78
|
+
// An older vendored env-file without a remover. Writing an empty value is
|
|
79
|
+
// NOT a removal — `setEnvValue` refuses an empty value outright, so that
|
|
80
|
+
// call returned an error and left the stale line in place. Unset the live
|
|
81
|
+
// variable and say plainly that the file still carries it.
|
|
82
|
+
if (envOpts.env) delete envOpts.env[name];
|
|
83
|
+
else delete process.env[name];
|
|
84
|
+
warn(
|
|
85
|
+
`removed ${provider}'s key in the app, but ${name} is still in the shared env file — `
|
|
86
|
+
+ 'this build has no clearEnvValue; delete the line by hand',
|
|
87
|
+
);
|
|
88
|
+
return null;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
warn(`could not mirror ${provider}'s key into the shared env file: ${e && e.message}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wrap a provider-settings store so every key saved or removed through it is
|
|
97
|
+
* also written to the shared env file.
|
|
98
|
+
*
|
|
99
|
+
* Wrapping the store (rather than each caller) is the whole point: the Settings
|
|
100
|
+
* pane, the welcome flow's provider rows and any future caller all funnel
|
|
101
|
+
* through `store.set` / `store.remove`, so there is exactly one place that can
|
|
102
|
+
* forget to mirror — and this is it.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} store a settings store (settings.get/set/remove/list)
|
|
105
|
+
* @param {object} envFile client/env-file.js
|
|
106
|
+
* @param {object} [opts] `onWarn`, `envOpts` (see above)
|
|
107
|
+
* @returns {object} the same store, wrapped
|
|
108
|
+
*/
|
|
109
|
+
function wrapProviderKeyMirror(store, envFile, opts = {}) {
|
|
110
|
+
if (!store || typeof store.set !== 'function') return store;
|
|
111
|
+
const mirror = (provider, key) => mirrorProviderKeyToEnvFile(envFile, provider, key, opts);
|
|
112
|
+
|
|
113
|
+
const rawSet = store.set.bind(store);
|
|
114
|
+
const rawRemove = typeof store.remove === 'function' ? store.remove.bind(store) : null;
|
|
115
|
+
|
|
116
|
+
store.set = (provider, cfg) => {
|
|
117
|
+
const res = rawSet(provider, cfg);
|
|
118
|
+
// A row with no key is a base-URL/local-daemon row: there is no secret to
|
|
119
|
+
// share, and mirroring would write an empty variable into a file every host
|
|
120
|
+
// sources. `cfg.key` undefined OR empty both mean "no key supplied"
|
|
121
|
+
// (a blank field in the pane means "keep the stored one").
|
|
122
|
+
if (cfg && typeof cfg.key === 'string' && cfg.key) mirror(provider, cfg.key);
|
|
123
|
+
return res;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (rawRemove) {
|
|
127
|
+
store.remove = (provider) => {
|
|
128
|
+
const res = rawRemove(provider);
|
|
129
|
+
mirror(provider, '');
|
|
130
|
+
return res;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return store;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { mirrorProviderKeyToEnvFile, wrapProviderKeyMirror };
|
package/main.js
CHANGED
|
@@ -2600,6 +2600,31 @@ function bootstrap() {
|
|
|
2600
2600
|
dir: dataDir,
|
|
2601
2601
|
});
|
|
2602
2602
|
|
|
2603
|
+
// A provider key typed in the Settings pane used to reach ONLY this app's
|
|
2604
|
+
// encrypted store, so `aegiscode` (the CLI) and the MCP plugin never saw it:
|
|
2605
|
+
// same machine, same provider, key asked for twice, and a key entered here
|
|
2606
|
+
// was gone from every other host on the next run. Mirror every key write into
|
|
2607
|
+
// the shared `~/.aegiscode/.env` — the one file all four hosts read at
|
|
2608
|
+
// start-up (loadEnvFile() above; cli/bin/aegiscode.js before the CLI runs) —
|
|
2609
|
+
// so "add the key in the app" and "put it in ~/.aegiscode/.env" are the same
|
|
2610
|
+
// instruction. The encrypted store stays this app's authority; the file is
|
|
2611
|
+
// what makes the key exist for the others. The AEGIS *account* key is
|
|
2612
|
+
// deliberately not mirrored: it is already shared via credentials.json, and a
|
|
2613
|
+
// second copy is a second authority that can disagree (see the module's
|
|
2614
|
+
// header). Wrapped before registerModelIpc below, so every IPC path uses the
|
|
2615
|
+
// wrapped store.
|
|
2616
|
+
try {
|
|
2617
|
+
const { wrapProviderKeyMirror } = require('./lib/provider-key-mirror.js');
|
|
2618
|
+
const onWarn = (m) => console.warn(`aegis: ${m}`);
|
|
2619
|
+
wrapProviderKeyMirror(settings, envFile, { onWarn });
|
|
2620
|
+
if (engine && engine.settings && engine.settings !== settings) {
|
|
2621
|
+
wrapProviderKeyMirror(engine.settings, envFile, { onWarn });
|
|
2622
|
+
}
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
// Never fatal: the store still works, only the cross-host copy is missing.
|
|
2625
|
+
console.warn(`aegis: could not wire the shared env-file key mirror: ${e && e.message}`);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2603
2628
|
// Persist the in-app AEGIS key in its own reserved namespace, encrypted —
|
|
2604
2629
|
// never as a provider named 'aegis' (that coupling let the Settings pane's
|
|
2605
2630
|
// "Remove" delete the AEGIS key; defect #1).
|
|
@@ -2610,6 +2635,10 @@ function bootstrap() {
|
|
|
2610
2635
|
// `savedAt` stamps so whichever host wrote last is the key in force.
|
|
2611
2636
|
const persistApiKey = (key) => {
|
|
2612
2637
|
const result = settings.setAegisKey(key);
|
|
2638
|
+
// NOT mirrored into ~/.aegiscode/.env on purpose — the line below already
|
|
2639
|
+
// shares this key with every host (credentials.json, 0600, read by the CLI,
|
|
2640
|
+
// the MCP plugin and this app). Two stores for one account key is two
|
|
2641
|
+
// answers to "which key is in force"; see desktop/lib/provider-key-mirror.js.
|
|
2613
2642
|
if (key) credentials.saveApiKey(key);
|
|
2614
2643
|
else credentials.clearApiKey();
|
|
2615
2644
|
return result;
|
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.10",
|
|
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
|
@@ -2236,6 +2236,38 @@ function applyWelcomeConnect() {
|
|
|
2236
2236
|
box.hidden = dismissed || keyConfigured === true || providerConfigured === true;
|
|
2237
2237
|
}
|
|
2238
2238
|
|
|
2239
|
+
/**
|
|
2240
|
+
* Land the user on the ONE provider row a just-picked byok model needs a key
|
|
2241
|
+
* for — the per-selection counterpart to `welcomeByok()`'s whole-class reveal.
|
|
2242
|
+
*
|
|
2243
|
+
* Before this, picking an unconfigured row in the model dropdown (deliberately
|
|
2244
|
+
* still offered — see `loadModels()`'s `needsProviderKey` hint, which engine.js
|
|
2245
|
+
* documents as "the catalog answers with no key at all, hiding the row would
|
|
2246
|
+
* hide the answer to which key to get") did nothing: the class-level hint only
|
|
2247
|
+
* fires when NO provider is configured, so a machine with one working key and
|
|
2248
|
+
* nine unconfigured ones got no help picking a row among the nine — just a
|
|
2249
|
+
* turn that fails downstream at the relay with no picker-side context. This
|
|
2250
|
+
* is the CLI's `collectKeyFor` (chatflow.js), for this host: same question
|
|
2251
|
+
* (does this row need a key this machine does not have), answered by pointing
|
|
2252
|
+
* at where to type it rather than reading it inline, because that is how this
|
|
2253
|
+
* app's Settings pane already works for every other key.
|
|
2254
|
+
*
|
|
2255
|
+
* `dataset.provider` on a byok row is `byok:<id>` (see the Provider settings
|
|
2256
|
+
* build loop above), not the bare provider id `modelMeta` rows carry.
|
|
2257
|
+
*/
|
|
2258
|
+
function focusProviderKeyRow(provider) {
|
|
2259
|
+
if (!els.settingsList || !provider) return;
|
|
2260
|
+
const row = els.settingsList.querySelector(
|
|
2261
|
+
`.setting-row[data-provider="byok:${provider}"] input[type="password"]`
|
|
2262
|
+
);
|
|
2263
|
+
revealSidebarCard(els.settingsList);
|
|
2264
|
+
if (row) row.focus();
|
|
2265
|
+
if (els.modelHint) {
|
|
2266
|
+
const base = els.modelHint.textContent.replace(/ — no .* key saved; add it below\.$/, '');
|
|
2267
|
+
els.modelHint.textContent = `${base} — no ${provider} key saved; add it below.`;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2239
2271
|
/** Scroll a sidebar card into view and mark it, so a welcome click has a
|
|
2240
2272
|
* visible landing spot. The sidebar is a plain scroll container, so this is
|
|
2241
2273
|
* the whole of the "open settings" affordance. */
|
|
@@ -4088,13 +4120,20 @@ async function init() {
|
|
|
4088
4120
|
});
|
|
4089
4121
|
|
|
4090
4122
|
els.modelSelect.addEventListener('change', () => {
|
|
4123
|
+
const meta = modelMeta.get(els.modelSelect.value);
|
|
4091
4124
|
// Display-only (see budget.js): the model's own advertised output limit,
|
|
4092
4125
|
// which is never the number this app puts on a request.
|
|
4093
|
-
const ceiling = maxTokensCeiling(
|
|
4126
|
+
const ceiling = maxTokensCeiling(meta);
|
|
4094
4127
|
const base = els.modelHint.textContent.replace(/ · max output: [\d,]+$/, '');
|
|
4095
4128
|
els.modelHint.textContent =
|
|
4096
4129
|
ceiling < FLAT_CEILING ? `${base} · max output: ${ceiling.toLocaleString()}` : base;
|
|
4097
4130
|
updateBudgetControls(els.classSelect.value);
|
|
4131
|
+
// Picking a byok row this machine holds no key for: point at where the
|
|
4132
|
+
// key goes, right now, instead of a turn that fails at the relay with no
|
|
4133
|
+
// picker-side context. See focusProviderKeyRow's doc for why this exists.
|
|
4134
|
+
if (els.classSelect.value === 'byok' && meta && meta.provider && meta.configured === false) {
|
|
4135
|
+
focusProviderKeyRow(meta.provider);
|
|
4136
|
+
}
|
|
4098
4137
|
});
|
|
4099
4138
|
|
|
4100
4139
|
// The typed model tag IS the selection while the box is visible (see
|
package/vendor/brain-catalog.js
CHANGED
|
@@ -105,9 +105,84 @@ function filterAegisCatalog(models) {
|
|
|
105
105
|
return [{ ...rest, label: NEXUS_LABEL }];
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The Aegis-class catalog as a host must OFFER it: the FULL list the server
|
|
110
|
+
* advertises — the pooled brain first, under its one name, then every other
|
|
111
|
+
* model the account can pin.
|
|
112
|
+
*
|
|
113
|
+
* `filterAegisCatalog` above collapses the class to the single brain row, and
|
|
114
|
+
* that was shipped: the desktop's dropdown and the terminal's alt+p picker both
|
|
115
|
+
* showed one entry, "Nexus", because collapsing was implemented to end a real
|
|
116
|
+
* disagreement (seven rows in one host, one in the other). Ending the
|
|
117
|
+
* disagreement by removing the choice was the wrong fix, and a user with a
|
|
118
|
+
* funded account could see no way to pin the deepseek or anthropic models the
|
|
119
|
+
* same catalogue advertises — the model was chosen for them, with no lever.
|
|
120
|
+
*
|
|
121
|
+
* So this is the rule both hosts now share: offer everything, collapse only the
|
|
122
|
+
* *aliases*.
|
|
123
|
+
*
|
|
124
|
+
* - The brain tier stays ONE row, labelled `NEXUS_LABEL`, first. The server
|
|
125
|
+
* lists it under five spellings (`nexus-brain` canonical, `aegis-brain` and
|
|
126
|
+
* the `-smart`/`-neo` tiers as `hidden: true, alias_of: "nexus-brain"`);
|
|
127
|
+
* those name one route, so they are folded into that one row rather than
|
|
128
|
+
* painted as five models — which is what `filterAegisCatalog` was for, and
|
|
129
|
+
* that half of it is kept.
|
|
130
|
+
* - Every other entry is offered as the server advertises it, INCLUDING
|
|
131
|
+
* entries the payload marks `hidden`, because "hidden" in this payload means
|
|
132
|
+
* "not the canonical spelling", not "do not offer".
|
|
133
|
+
* - An entry that is an alias OF another entry in the same payload is dropped
|
|
134
|
+
* only when its target is present — a pure duplicate row that would pin the
|
|
135
|
+
* id it points at, which is the `models.js` defect the CLI already guarded
|
|
136
|
+
* against. When the payload is nothing but aliases, they are all offered:
|
|
137
|
+
* an empty list is not a better answer than a list of aliases.
|
|
138
|
+
*
|
|
139
|
+
* Pure, like the rest of this file, and shape-tolerant (raw relay payload or
|
|
140
|
+
* the CLI's normalised entries) so both hosts can call it with what they hold.
|
|
141
|
+
* Returns `[]` for an empty payload; never invents an entry.
|
|
142
|
+
*
|
|
143
|
+
* @param {Array<object>} models the server's catalog (any shape).
|
|
144
|
+
* @returns {Array<object>} the entries to show, brain first (a new array).
|
|
145
|
+
*/
|
|
146
|
+
function offerableCatalog(models) {
|
|
147
|
+
const list = Array.isArray(models) ? models.filter(Boolean) : [];
|
|
148
|
+
if (!list.length) return [];
|
|
149
|
+
|
|
150
|
+
const brain = selectBrainEntry(list);
|
|
151
|
+
const out = [];
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
|
|
154
|
+
if (brain) {
|
|
155
|
+
// Same de-aliasing as filterAegisCatalog: this row IS the selection, so no
|
|
156
|
+
// renderer may filter it back out as a hidden alias.
|
|
157
|
+
const { hidden, alias_of, aliasOf, ...rest } = brain;
|
|
158
|
+
out.push({ ...rest, label: NEXUS_LABEL });
|
|
159
|
+
for (const id of NEXUS_BRAIN_IDS) seen.add(id);
|
|
160
|
+
if (typeof brain.id === 'string') seen.add(brain.id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ids = new Set(
|
|
164
|
+
list.map((m) => (typeof m.id === 'string' ? m.id.trim() : '')).filter(Boolean)
|
|
165
|
+
);
|
|
166
|
+
const aliasTargets = new Set(
|
|
167
|
+
list.map((m) => aliasOfEntry(m)).filter((t) => t && ids.has(t))
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
for (const m of list) {
|
|
171
|
+
const id = typeof m.id === 'string' ? m.id.trim() : '';
|
|
172
|
+
if (!id || seen.has(id)) continue;
|
|
173
|
+
// A row whose target is present is a duplicate pin, not a second model.
|
|
174
|
+
// Dropped here rather than per host, so the two hosts cannot disagree.
|
|
175
|
+
if (aliasOfEntry(m) && aliasTargets.has(aliasOfEntry(m))) continue;
|
|
176
|
+
seen.add(id);
|
|
177
|
+
out.push(m);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
108
182
|
module.exports = {
|
|
109
183
|
NEXUS_BRAIN_IDS,
|
|
110
184
|
NEXUS_LABEL,
|
|
111
185
|
selectBrainEntry,
|
|
112
186
|
filterAegisCatalog,
|
|
187
|
+
offerableCatalog,
|
|
113
188
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BYOK model additions — the models a host OFFERS for a provider that the
|
|
5
|
+
* server's own catalog does not list.
|
|
6
|
+
*
|
|
7
|
+
* ── why a client-side addition is legitimate here, and only here ────────────
|
|
8
|
+
*
|
|
9
|
+
* For the POOLED class it would not be. `client/brain-catalog.js` exists
|
|
10
|
+
* precisely because a host may not invent an id: the pool can only address what
|
|
11
|
+
* it advertises, so an id a client made up would pin, look accepted, and 404 on
|
|
12
|
+
* the first turn. `/api/v1/models` is the whole list, and the rule is "offer
|
|
13
|
+
* what the server sends".
|
|
14
|
+
*
|
|
15
|
+
* BYOK is the opposite shape, and the server says so itself
|
|
16
|
+
* (services/byok_service.py, `byok_provider_catalog`): "the pooled route cannot
|
|
17
|
+
* address it, but BYOK relays whatever model the caller names, so it stays
|
|
18
|
+
* usable and stays listed". The relay is a stateless pass-through — the
|
|
19
|
+
* caller's own vendor key authenticates upstream, and AEGIS forwards the model
|
|
20
|
+
* string it was given. So for a provider the account holds a key for, an id the
|
|
21
|
+
* *vendor* serves is callable whether or not AEGIS's catalog happens to name
|
|
22
|
+
* it. That is the whole reason this file is allowed to exist.
|
|
23
|
+
*
|
|
24
|
+
* ── what was actually missing ──────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* The server derives each provider's `models` from MODEL_CATALOG, and for
|
|
27
|
+
* `deepseek` that catalog holds one descriptor (`default_model
|
|
28
|
+
* "deepseek-v4-flash"`, services/nexus_provider/catalog.py). Verified live
|
|
29
|
+
* 2026-09-18: `GET https://aegiscloud.org/api/v1/byok/providers` answers
|
|
30
|
+
* `deepseek` with `models: ["deepseek-v4-flash"]` and nothing else.
|
|
31
|
+
*
|
|
32
|
+
* So a user who brought a DeepSeek key saw exactly one pinnable model, and the
|
|
33
|
+
* 4.1 Flash — `deepseek-flash`, the spelling whose rate row, reasoning-token
|
|
34
|
+
* budget and effort rung this repo ALREADY carries (desktop/renderer/
|
|
35
|
+
* budget.js, desktop/lib/local/engine.js DEEPSEEK_REASONING_MODEL_RE, and the
|
|
36
|
+
* rate table in desktop/renderer/usage.js: "deepseek-flash, i.e. Flash 4.1")
|
|
37
|
+
* — was unreachable from the picker, with no hint that the key they had just
|
|
38
|
+
* pasted could call it.
|
|
39
|
+
*
|
|
40
|
+
* ── the rules this module keeps ────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* - ONE spelling per route. `deepseek-v4.1-flash` is the legacy alias the
|
|
43
|
+
* same regexes accept; listing both would paint two rows that pin one
|
|
44
|
+
* model, which is the "five spellings of one brain" defect the sibling
|
|
45
|
+
* brain-catalog module exists to end. The canonical 4.1 id is
|
|
46
|
+
* `deepseek-flash`, so that is the one added.
|
|
47
|
+
* - NEVER invent a provider. Only a provider the payload already lists is
|
|
48
|
+
* augmented. A provider row is a slot that accepts a vendor key; a row a
|
|
49
|
+
* client fabricated would invite a key for a slug the relay may not accept,
|
|
50
|
+
* and the failure would land on the user's first turn.
|
|
51
|
+
* - Idempotent and non-mutating. A payload that already carries the id comes
|
|
52
|
+
* back with the same provider objects untouched, so this is safe to apply
|
|
53
|
+
* twice (a caller that adds at fetch time and again before rendering cannot
|
|
54
|
+
* double-list) and safe to apply to a shared payload.
|
|
55
|
+
* - The server's order is preserved and additions are APPENDED, so the
|
|
56
|
+
* server's `default_model` keeps its first position — the default is what a
|
|
57
|
+
* bare provider id runs, and a client that reordered it would silently
|
|
58
|
+
* change which model a user gets for typing nothing.
|
|
59
|
+
*
|
|
60
|
+
* Pure: no I/O, no node builtins, no dependencies — requireable from a
|
|
61
|
+
* renderer, the CLI start-up path, the desktop main process, or an MCP host.
|
|
62
|
+
* Shared by the CLI and the desktop through one file for the same reason
|
|
63
|
+
* brain-catalog.js is: two copies is how those two hosts came to show one
|
|
64
|
+
* account two different model lists.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* provider id → extra models to offer, in the order to append them.
|
|
69
|
+
*
|
|
70
|
+
* Frozen, and deliberately tiny: every entry here is a claim that the vendor
|
|
71
|
+
* serves an id the AEGIS catalog does not advertise. The durable fix for any of
|
|
72
|
+
* them is the server's MODEL_CATALOG (aegis1 services/nexus_provider/
|
|
73
|
+
* catalog.py) — this map is what keeps the two clients useful until that
|
|
74
|
+
* lands, and an entry must be deleted once the server lists the id itself
|
|
75
|
+
* (`withByokAdditions` then no longer reports it as an addition).
|
|
76
|
+
*/
|
|
77
|
+
const BYOK_MODEL_ADDITIONS = Object.freeze({
|
|
78
|
+
// DeepSeek's 4.1 Flash. `deepseek-flash` is the spelling DeepSeek's own
|
|
79
|
+
// current API uses and the one this repo's rate table, token-budget and
|
|
80
|
+
// reasoning-model regexes all already key off; `deepseek-v4.1-flash` is the
|
|
81
|
+
// retired alias for the same route and is NOT listed separately.
|
|
82
|
+
deepseek: Object.freeze(['deepseek-flash']),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** The extras to offer for one provider id (an empty array when there are none). */
|
|
86
|
+
function addedModelsFor(providerId) {
|
|
87
|
+
const id = typeof providerId === 'string' ? providerId.trim() : '';
|
|
88
|
+
const extra = id ? BYOK_MODEL_ADDITIONS[id] : null;
|
|
89
|
+
return Array.isArray(extra) ? extra.slice() : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The model ids an entry names, tolerating the server's two shapes. */
|
|
93
|
+
function modelIdsOf(provider) {
|
|
94
|
+
return (Array.isArray(provider && provider.models) ? provider.models : [])
|
|
95
|
+
.map((m) => (typeof m === 'string' ? m : m && m.id))
|
|
96
|
+
.map((m) => (typeof m === 'string' ? m.trim() : ''))
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The server's BYOK provider catalog with the additions above applied.
|
|
102
|
+
*
|
|
103
|
+
* @param {Array<object>|object} providers the `providers` array from
|
|
104
|
+
* `GET /api/v1/byok/providers`, or the whole `{providers: [...]}` payload.
|
|
105
|
+
* @returns {Array<object>} a new array; provider objects are shared unless the
|
|
106
|
+
* row actually gained a model, and an empty/absent payload returns `[]`
|
|
107
|
+
* rather than a fabricated catalog.
|
|
108
|
+
*/
|
|
109
|
+
function withByokAdditions(providers) {
|
|
110
|
+
const raw = Array.isArray(providers)
|
|
111
|
+
? providers
|
|
112
|
+
: Array.isArray(providers && providers.providers)
|
|
113
|
+
? providers.providers
|
|
114
|
+
: null;
|
|
115
|
+
if (!raw) return [];
|
|
116
|
+
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const p of raw) {
|
|
119
|
+
if (!p || typeof p !== 'object') continue;
|
|
120
|
+
const id = typeof p.id === 'string' ? p.id.trim() : '';
|
|
121
|
+
const extra = id ? BYOK_MODEL_ADDITIONS[id] : null;
|
|
122
|
+
if (!Array.isArray(extra) || !extra.length) {
|
|
123
|
+
out.push(p);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const have = new Set(modelIdsOf(p));
|
|
127
|
+
const missing = extra.filter((m) => !have.has(m));
|
|
128
|
+
// Nothing to add: hand back the SAME object, so an already-complete payload
|
|
129
|
+
// is byte-identical after this pass and callers cannot tell it ran.
|
|
130
|
+
if (!missing.length) {
|
|
131
|
+
out.push(p);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const models = Array.isArray(p.models) ? p.models : [];
|
|
135
|
+
out.push({ ...p, models: [...models, ...missing] });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
BYOK_MODEL_ADDITIONS,
|
|
142
|
+
addedModelsFor,
|
|
143
|
+
withByokAdditions,
|
|
144
|
+
};
|
package/vendor/env-file.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The shared `~/.aegiscode/.env`
|
|
5
|
-
*
|
|
6
|
-
* One file holding every key this product family can use, for every host:
|
|
4
|
+
* The shared `~/.aegiscode/.env` store — the one file holding every key this
|
|
5
|
+
* product family can use, for every host:
|
|
7
6
|
*
|
|
8
7
|
* AEGIS_API_KEY=aegis_…
|
|
9
8
|
* OPENAI_API_KEY=sk-…
|
|
@@ -18,14 +17,39 @@
|
|
|
18
17
|
* needs no other change in either, because `AEGIS_API_KEY` is already the FIRST
|
|
19
18
|
* entry in `client/credentials.js`'s documented resolution order.
|
|
20
19
|
*
|
|
21
|
-
*
|
|
20
|
+
* READING still obeys two rules this module started with:
|
|
22
21
|
*
|
|
23
22
|
* 1. A variable already present in the environment is never overwritten. The
|
|
24
23
|
* file is a convenience for the common case, not a way for a stale file to
|
|
25
24
|
* shadow an explicit `AEGIS_API_KEY=… aegiscode` in CI.
|
|
26
|
-
* 2.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* 2. A load never rewrites anything. If the file the user made is readable by
|
|
26
|
+
* other accounts we say so rather than fixing it silently.
|
|
27
|
+
*
|
|
28
|
+
* WRITING was added later, because the file being read-only was the reason the
|
|
29
|
+
* key a user typed at the model picker did not survive the session: the only
|
|
30
|
+
* place a host could put it was its own encrypted store, so the CLI and the
|
|
31
|
+
* desktop each held a private key for the same provider and the user had to
|
|
32
|
+
* enter it twice. The demand is one key, entered once at the model picker,
|
|
33
|
+
* stored where BOTH hosts already look. So `setEnvValue` is the single writer:
|
|
34
|
+
*
|
|
35
|
+
* - the variable name is validated (`/^[A-Za-z_][A-Za-z0-9_]*$/`) and the
|
|
36
|
+
* value may not contain a newline, so a key can never inject a second line
|
|
37
|
+
* (or an unrelated variable) into a file every host sources at start-up;
|
|
38
|
+
* - an existing line with the same name is REPLACED in place — including its
|
|
39
|
+
* `export ` prefix, which is preserved — so comments, ordering and every
|
|
40
|
+
* other key in the file survive untouched, and a hand-written file is not
|
|
41
|
+
* reformatted;
|
|
42
|
+
* - duplicate definitions of the same name are collapsed to the one that was
|
|
43
|
+
* just written. Two `OPENAI_API_KEY=` lines is the "which one won?" bug,
|
|
44
|
+
* and the answer would otherwise depend on the reader's merge order;
|
|
45
|
+
* - the file is created 0600 and written through a same-directory temp file
|
|
46
|
+
* + rename, so a crash mid-write cannot leave a half-written key file, and
|
|
47
|
+
* a write never widens permissions on a file that was already tighter. A
|
|
48
|
+
* file that WAS group/world-readable is tightened, and the caller is told
|
|
49
|
+
* (`tightened: true`) rather than the change going unmentioned;
|
|
50
|
+
* - the value is applied to `process.env` as well, so the selection that
|
|
51
|
+
* triggered the write runs on the key it just stored;
|
|
52
|
+
* - the value is never returned, logged or echoed by anything in here.
|
|
29
53
|
*/
|
|
30
54
|
|
|
31
55
|
const fs = require('node:fs');
|
|
@@ -34,6 +58,12 @@ const credentials = require('./credentials.js');
|
|
|
34
58
|
|
|
35
59
|
const ENV_FILE = '.env';
|
|
36
60
|
|
|
61
|
+
/** Permissions for a file holding secrets: owner read/write, nothing else. */
|
|
62
|
+
const SECRET_FILE_MODE = 0o600;
|
|
63
|
+
|
|
64
|
+
/** A directory we create for a secret file: owner only. */
|
|
65
|
+
const SECRET_DIR_MODE = 0o700;
|
|
66
|
+
|
|
37
67
|
/**
|
|
38
68
|
* provider id -> the env var a BYOK key is conventionally exported as, for the
|
|
39
69
|
* ids where the generic rule below would spell the wrong name. The generic rule
|
|
@@ -56,6 +86,9 @@ const ALIASES = Object.freeze({
|
|
|
56
86
|
'voyage-ai': 'VOYAGE_API_KEY',
|
|
57
87
|
});
|
|
58
88
|
|
|
89
|
+
/** The variable that carries an AEGIS Cloud account key (see credentials.js). */
|
|
90
|
+
const ACCOUNT_ENV_VAR = 'AEGIS_API_KEY';
|
|
91
|
+
|
|
59
92
|
/** The file this host would read, honouring `$AEGISCODE_HOME`. */
|
|
60
93
|
function envFileFor(dir) {
|
|
61
94
|
return path.join(dir || credentials.aegisHome(), ENV_FILE);
|
|
@@ -85,7 +118,9 @@ function envVarFor(providerId) {
|
|
|
85
118
|
* (and a stray `#` comment or a blank line is normal, not an error).
|
|
86
119
|
*
|
|
87
120
|
* Handles `export `, `KEY=value`, `KEY="value"`, `KEY='value'` and a trailing
|
|
88
|
-
* ` # comment` on an unquoted value.
|
|
121
|
+
* ` # comment` on an unquoted value. A double-quoted value is unescaped (`\"`,
|
|
122
|
+
* `\\`) because that is what `quoteEnvValue` writes and what dotenv readers do;
|
|
123
|
+
* a single-quoted value is literal.
|
|
89
124
|
*/
|
|
90
125
|
function parseEnvText(text) {
|
|
91
126
|
const out = {};
|
|
@@ -98,9 +133,19 @@ function parseEnvText(text) {
|
|
|
98
133
|
if (name.startsWith('export ')) name = name.slice('export '.length).trim();
|
|
99
134
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
|
|
100
135
|
let value = line.slice(eq + 1).trim();
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (
|
|
136
|
+
const doubleQuoted = value.startsWith('"') && value.endsWith('"') && value.length >= 2;
|
|
137
|
+
const singleQuoted = value.startsWith("'") && value.endsWith("'") && value.length >= 2;
|
|
138
|
+
if (doubleQuoted) {
|
|
139
|
+
// Double quotes are the one form that carries escapes, so they are the
|
|
140
|
+
// one form that must be unescaped — `\"` and `\\`, exactly what
|
|
141
|
+
// `quoteEnvValue` writes and what every dotenv reader does. Without this
|
|
142
|
+
// the writer's escaping was one-way: a value containing a quote or a
|
|
143
|
+
// backslash was stored doubled and read back mangled, so a key survived
|
|
144
|
+
// the session it was typed in but not the next one. Single quotes stay
|
|
145
|
+
// literal (`'C:\keys'` is a backslash, not an escape), which is the
|
|
146
|
+
// convention that makes the two quote styles mean something different.
|
|
147
|
+
value = value.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
148
|
+
} else if (singleQuoted) {
|
|
104
149
|
value = value.slice(1, -1);
|
|
105
150
|
} else {
|
|
106
151
|
const hash = value.indexOf(' #');
|
|
@@ -171,12 +216,354 @@ function providerKeyFromEnv(providerId, env) {
|
|
|
171
216
|
return { key, env: name };
|
|
172
217
|
}
|
|
173
218
|
|
|
219
|
+
/** A variable name this module is willing to write. */
|
|
220
|
+
function isWritableName(name) {
|
|
221
|
+
return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Quote a value the way `parseEnvText` (and every dotenv reader) will read back
|
|
226
|
+
* byte-identically. Unquoted when it is safe; double-quoted with backslashes and
|
|
227
|
+
* quotes escaped when it is not. A newline is refused by the caller, never
|
|
228
|
+
* escaped here: escaping it would hide a malformed key behind a file that
|
|
229
|
+
* still looks right.
|
|
230
|
+
*/
|
|
231
|
+
function quoteEnvValue(value) {
|
|
232
|
+
const s = String(value);
|
|
233
|
+
if (s === '') return '';
|
|
234
|
+
if (!/[\s"'#\\]/.test(s)) return s;
|
|
235
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Apply `name=value` to dotenv text: replace the first existing definition in
|
|
240
|
+
* place (keeping an `export ` prefix and any trailing comment-less formatting),
|
|
241
|
+
* drop later duplicates, and append when the name is new. Every other line —
|
|
242
|
+
* comments, blanks, unrelated keys, their order — is preserved verbatim.
|
|
243
|
+
*
|
|
244
|
+
* Pure, and exported for tests: this is the part worth pinning, because a bug
|
|
245
|
+
* here silently destroys a file the user also edits by hand.
|
|
246
|
+
*
|
|
247
|
+
* @returns {{text:string, replaced:boolean}}
|
|
248
|
+
*/
|
|
249
|
+
function upsertEnvText(text, name, value) {
|
|
250
|
+
const src = String(text == null ? '' : text);
|
|
251
|
+
// Split without keeping the terminator, then re-join with '\n' and restore a
|
|
252
|
+
// final newline only if the file had one — so a write never adds a dangling
|
|
253
|
+
// blank line to a file that ended cleanly.
|
|
254
|
+
const hadFinalNewline = src === '' ? true : src.endsWith('\n');
|
|
255
|
+
const trailingBlank = src === '' ? false : src.split('\n').slice(-1)[0] === '';
|
|
256
|
+
const lines = src.split('\n');
|
|
257
|
+
if (trailingBlank) lines.pop();
|
|
258
|
+
|
|
259
|
+
const re = new RegExp(`^(\\s*(?:export\\s+)?)${name}\\s*=`);
|
|
260
|
+
const out = [];
|
|
261
|
+
let replaced = false;
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
const m = re.exec(line);
|
|
264
|
+
if (!m) {
|
|
265
|
+
out.push(line);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (replaced) continue; // a second definition of the same name: collapse it
|
|
269
|
+
out.push(`${m[1]}${name}=${quoteEnvValue(value)}`);
|
|
270
|
+
replaced = true;
|
|
271
|
+
}
|
|
272
|
+
if (!replaced) out.push(`${name}=${quoteEnvValue(value)}`);
|
|
273
|
+
|
|
274
|
+
let joined = out.join('\n');
|
|
275
|
+
if (joined !== '' && (hadFinalNewline || trailingBlank)) joined += '\n';
|
|
276
|
+
return { text: joined, replaced };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Store one variable in `~/.aegiscode/.env`, creating the file (0600) and the
|
|
281
|
+
* directory (0700) when needed, and apply it to the live environment so the
|
|
282
|
+
* action that triggered the write runs on it.
|
|
283
|
+
*
|
|
284
|
+
* The one writer for every host: the CLI calls it when a model is selected and
|
|
285
|
+
* its key is missing, and the desktop calls the same function through the same
|
|
286
|
+
* staged file, which is what makes a key entered once usable in both.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} name an env var name, e.g. 'OPENAI_API_KEY'
|
|
289
|
+
* @param {string} value the secret. Never returned, never logged.
|
|
290
|
+
* @param {object} [o]
|
|
291
|
+
* @param {string} [o.dir] defaults to aegisHome()
|
|
292
|
+
* @param {string} [o.file] an explicit path, for tests
|
|
293
|
+
* @param {object} [o.env] defaults to process.env
|
|
294
|
+
* @param {boolean} [o.apply=true] set it in `o.env`/process.env as well
|
|
295
|
+
* @returns {{ok:boolean, file:string, name:string, changed:boolean,
|
|
296
|
+
* created:boolean, replaced:boolean, mode:number, tightened:boolean,
|
|
297
|
+
* error:string}}
|
|
298
|
+
* `changed` is false when the file already held that exact value — a re-save
|
|
299
|
+
* of an unchanged key must not rewrite the file.
|
|
300
|
+
*/
|
|
301
|
+
function setEnvValue(name, value, o = {}) {
|
|
302
|
+
const env = o.env || process.env;
|
|
303
|
+
const file = o.file || envFileFor(o.dir);
|
|
304
|
+
const out = {
|
|
305
|
+
ok: false, file, name: String(name || ''), changed: false, created: false,
|
|
306
|
+
replaced: false, mode: 0, tightened: false, error: '',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (!isWritableName(name)) {
|
|
310
|
+
out.error = `refusing to write "${name}" — an env var name is [A-Za-z_][A-Za-z0-9_]*`;
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
const secret = String(value == null ? '' : value).trim();
|
|
314
|
+
if (!secret) {
|
|
315
|
+
out.error = `refusing to write an empty ${name}`;
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
// The one injection this file must not accept: a value carrying a newline
|
|
319
|
+
// would append a line, and the file is sourced by every host at start-up.
|
|
320
|
+
if (/[\r\n]/.test(String(value))) {
|
|
321
|
+
out.error = `refusing to write ${name} — the value contains a line break`;
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let before = '';
|
|
326
|
+
let previousMode = 0;
|
|
327
|
+
out.created = true;
|
|
328
|
+
try {
|
|
329
|
+
before = fs.readFileSync(file, 'utf8');
|
|
330
|
+
out.created = false;
|
|
331
|
+
try {
|
|
332
|
+
previousMode = fs.statSync(file).mode & 0o777;
|
|
333
|
+
} catch { /* advisory */ }
|
|
334
|
+
} catch (e) {
|
|
335
|
+
if (e && e.code !== 'ENOENT') {
|
|
336
|
+
out.error = `could not read ${file}: ${e.code || e.message}`;
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const aligned = String(before).replace(/\r\n/g, '\n');
|
|
342
|
+
if (!out.created && parseEnvText(aligned)[name] === secret) {
|
|
343
|
+
// Same key already stored: leave the file (its mtime, its mode) alone.
|
|
344
|
+
out.ok = true;
|
|
345
|
+
out.changed = false;
|
|
346
|
+
out.replaced = true;
|
|
347
|
+
out.mode = previousMode || SECRET_FILE_MODE;
|
|
348
|
+
if (o.apply !== false) env[name] = secret;
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const { text, replaced } = upsertEnvText(aligned, name, secret);
|
|
353
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
354
|
+
try {
|
|
355
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: SECRET_DIR_MODE });
|
|
356
|
+
fs.writeFileSync(tmp, text, { encoding: 'utf8', mode: SECRET_FILE_MODE });
|
|
357
|
+
// The rename carries the temp file's 0600 onto the destination, so a file
|
|
358
|
+
// that was group-readable ends up owner-only. Say so rather than tightening
|
|
359
|
+
// a file behind the user's back.
|
|
360
|
+
fs.chmodSync(tmp, SECRET_FILE_MODE);
|
|
361
|
+
fs.renameSync(tmp, file);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
364
|
+
out.error = `could not write ${file}: ${e.code || e.message}`;
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
out.ok = true;
|
|
369
|
+
out.changed = true;
|
|
370
|
+
out.replaced = replaced;
|
|
371
|
+
out.mode = SECRET_FILE_MODE;
|
|
372
|
+
out.tightened = !out.created && previousMode !== 0 && (previousMode & 0o077) !== 0;
|
|
373
|
+
if (o.apply !== false) env[name] = secret;
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Delete every definition of `name` from an env file's text, leaving every
|
|
379
|
+
* other line — comments, blanks, unrelated keys, their order, `export `
|
|
380
|
+
* prefixes — byte-identical.
|
|
381
|
+
*
|
|
382
|
+
* Removing a key is NOT the same as storing an empty one: `parseEnvText` reads
|
|
383
|
+
* `NAME=""` back as a present-but-empty variable, so an empty write leaves the
|
|
384
|
+
* old value's line in the file and a stale key can still win later. A remover
|
|
385
|
+
* has to delete the line.
|
|
386
|
+
*
|
|
387
|
+
* Pure, and exported for tests: like `upsertEnvText`, a bug here silently
|
|
388
|
+
* damages a file the user also edits by hand.
|
|
389
|
+
*
|
|
390
|
+
* @returns {{text:string, removed:number, changed:boolean}}
|
|
391
|
+
*/
|
|
392
|
+
function removeEnvText(text, name) {
|
|
393
|
+
const src = String(text == null ? '' : text);
|
|
394
|
+
const hadFinalNewline = src === '' ? true : src.endsWith('\n');
|
|
395
|
+
const trailingBlank = src === '' ? false : src.split('\n').slice(-1)[0] === '';
|
|
396
|
+
const lines = src.split('\n');
|
|
397
|
+
if (trailingBlank) lines.pop();
|
|
398
|
+
|
|
399
|
+
// Same definition matcher as upsertEnvText, so the two can never disagree
|
|
400
|
+
// about which lines count as this variable.
|
|
401
|
+
const re = new RegExp(`^(\\s*(?:export\\s+)?)${name}\\s*=`);
|
|
402
|
+
const out = [];
|
|
403
|
+
let removed = 0;
|
|
404
|
+
for (const line of lines) {
|
|
405
|
+
if (re.test(line)) {
|
|
406
|
+
removed += 1; // every definition goes, so duplicates cannot survive
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
out.push(line);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let joined = out.join('\n');
|
|
413
|
+
if (joined !== '' && (hadFinalNewline || trailingBlank)) joined += '\n';
|
|
414
|
+
return { text: joined, removed, changed: removed > 0 };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Remove one variable from `~/.aegiscode/.env` and from the live environment.
|
|
419
|
+
*
|
|
420
|
+
* The counterpart of `setEnvValue`, and the reason a provider key deleted in
|
|
421
|
+
* the desktop app stops being in force: without it, the key survives in the
|
|
422
|
+
* shared file and the desktop's own "removed" state disagrees with what the CLI
|
|
423
|
+
* reads next session.
|
|
424
|
+
*
|
|
425
|
+
* A file that never mentioned the name is left untouched — no stray empty
|
|
426
|
+
* variable, no rewritten mtime.
|
|
427
|
+
*
|
|
428
|
+
* @returns {{ok:boolean, file:string, name:string, changed:boolean,
|
|
429
|
+
* removed:number, mode:number, error:string}}
|
|
430
|
+
*/
|
|
431
|
+
function clearEnvValue(name, o = {}) {
|
|
432
|
+
const env = o.env || process.env;
|
|
433
|
+
const file = o.file || envFileFor(o.dir);
|
|
434
|
+
const out = {
|
|
435
|
+
ok: false, file, name: String(name || ''), changed: false, removed: 0,
|
|
436
|
+
mode: 0, error: '',
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
if (!isWritableName(name)) {
|
|
440
|
+
out.error = `refusing to clear "${name}" — an env var name is [A-Za-z_][A-Za-z0-9_]*`;
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let before = '';
|
|
445
|
+
let missing = false;
|
|
446
|
+
let previousMode = 0;
|
|
447
|
+
try {
|
|
448
|
+
before = fs.readFileSync(file, 'utf8');
|
|
449
|
+
try {
|
|
450
|
+
previousMode = fs.statSync(file).mode & 0o777;
|
|
451
|
+
} catch { /* advisory */ }
|
|
452
|
+
} catch (e) {
|
|
453
|
+
if (e && e.code === 'ENOENT') missing = true;
|
|
454
|
+
else {
|
|
455
|
+
out.error = `could not read ${file}: ${e.code || e.message}`;
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { text, removed, changed } = missing
|
|
461
|
+
? { text: '', removed: 0, changed: false }
|
|
462
|
+
: removeEnvText(before, name);
|
|
463
|
+
|
|
464
|
+
if (!changed) {
|
|
465
|
+
// Nothing in the file to remove. Still unset the live variable: the caller
|
|
466
|
+
// asked for the key to stop being used, and a shell export would otherwise
|
|
467
|
+
// keep serving it for the rest of this process.
|
|
468
|
+
out.ok = true;
|
|
469
|
+
out.mode = previousMode || SECRET_FILE_MODE;
|
|
470
|
+
if (o.apply !== false) delete env[String(name)];
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
475
|
+
try {
|
|
476
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: SECRET_DIR_MODE });
|
|
477
|
+
fs.writeFileSync(tmp, text, { encoding: 'utf8', mode: SECRET_FILE_MODE });
|
|
478
|
+
fs.chmodSync(tmp, SECRET_FILE_MODE);
|
|
479
|
+
fs.renameSync(tmp, file);
|
|
480
|
+
} catch (e) {
|
|
481
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
482
|
+
out.error = `could not write ${file}: ${e.code || e.message}`;
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
out.ok = true;
|
|
487
|
+
out.changed = true;
|
|
488
|
+
out.removed = removed;
|
|
489
|
+
out.mode = SECRET_FILE_MODE;
|
|
490
|
+
if (o.apply !== false) delete env[String(name)];
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Whether the key a route needs is already stored, and where that answer came
|
|
496
|
+
* from. Read-only; the caller uses it to decide whether to prompt.
|
|
497
|
+
*
|
|
498
|
+
* `inFile` is the distinction that matters for the model picker: a key in the
|
|
499
|
+
* process environment belongs to the shell that launched this host and is gone
|
|
500
|
+
* next session (and invisible to the desktop), where a key in the file is the
|
|
501
|
+
* durable, cross-host one.
|
|
502
|
+
*
|
|
503
|
+
* @returns {{name:string, present:boolean, inFile:boolean, source:string}}
|
|
504
|
+
* `source` — 'shell' | 'file' | 'both' | 'none'
|
|
505
|
+
*/
|
|
506
|
+
function keyStatus(name, o = {}) {
|
|
507
|
+
const env = o.env || process.env;
|
|
508
|
+
const file = o.file || envFileFor(o.dir);
|
|
509
|
+
const varName = String(name || '');
|
|
510
|
+
const out = { name: varName, present: false, inFile: false, source: 'none' };
|
|
511
|
+
if (!isWritableName(varName)) return out;
|
|
512
|
+
|
|
513
|
+
const raw = env[varName];
|
|
514
|
+
const shell = typeof raw === 'string' && raw.trim() !== '';
|
|
515
|
+
let file_ = false;
|
|
516
|
+
try {
|
|
517
|
+
const parsed = parseEnvText(fs.readFileSync(file, 'utf8'));
|
|
518
|
+
file_ = typeof parsed[varName] === 'string' && parsed[varName].trim() !== '';
|
|
519
|
+
} catch { /* absent or unreadable is simply "not in the file" */ }
|
|
520
|
+
|
|
521
|
+
out.present = shell || file_;
|
|
522
|
+
out.inFile = file_;
|
|
523
|
+
out.source = shell && file_ ? 'both' : shell ? 'shell' : file_ ? 'file' : 'none';
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The env var a model id's key belongs in — the question the model picker asks
|
|
529
|
+
* to know what to collect, and the reason one key is not collected twice.
|
|
530
|
+
*
|
|
531
|
+
* A byok id is `<provider>:<model>` and its key is that provider's variable. A
|
|
532
|
+
* pooled id (`deepseek`, `anthropic-haiku`, `nexus-brain`, …) is served by the
|
|
533
|
+
* pool, which holds the provider keys itself, so the credential such a route
|
|
534
|
+
* needs is the AEGIS ACCOUNT key — one variable, `AEGIS_API_KEY`, however many
|
|
535
|
+
* providers the picker lists.
|
|
536
|
+
*
|
|
537
|
+
* @param {string} modelId
|
|
538
|
+
* @param {string} [cls] 'byok' | 'aegis' | anything else
|
|
539
|
+
* @returns {{env:string, provider:string, pooled:boolean}}
|
|
540
|
+
*/
|
|
541
|
+
function keyForModelId(modelId, cls) {
|
|
542
|
+
const id = String(modelId == null ? '' : modelId).trim();
|
|
543
|
+
const byok = String(cls || '').toLowerCase() === 'byok' || id.includes(':');
|
|
544
|
+
if (byok && id.includes(':')) {
|
|
545
|
+
const provider = id.slice(0, id.indexOf(':')).trim();
|
|
546
|
+
return { env: envVarFor(provider), provider, pooled: false };
|
|
547
|
+
}
|
|
548
|
+
return { env: ACCOUNT_ENV_VAR, provider: '', pooled: true };
|
|
549
|
+
}
|
|
550
|
+
|
|
174
551
|
module.exports = {
|
|
175
552
|
ENV_FILE,
|
|
176
553
|
ALIASES,
|
|
554
|
+
ACCOUNT_ENV_VAR,
|
|
555
|
+
SECRET_FILE_MODE,
|
|
177
556
|
envFileFor,
|
|
178
557
|
envVarFor,
|
|
179
558
|
parseEnvText,
|
|
180
559
|
loadEnvFile,
|
|
181
560
|
providerKeyFromEnv,
|
|
561
|
+
isWritableName,
|
|
562
|
+
quoteEnvValue,
|
|
563
|
+
upsertEnvText,
|
|
564
|
+
removeEnvText,
|
|
565
|
+
setEnvValue,
|
|
566
|
+
clearEnvValue,
|
|
567
|
+
keyStatus,
|
|
568
|
+
keyForModelId,
|
|
182
569
|
};
|