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.
@@ -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 filterAegisCatalog), so an
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))?)$/;
@@ -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 filterAegisCatalog in
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 filterAegisCatalog offers for the Aegis Cloud class, plus the
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
@@ -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 catalog (`/api/v1/models`) lists every backend the pool can
276
- * reach: per-provider ids (`openai`, `anthropic`, `groq`, `gemini`, ...) and
277
- * six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all run
278
- * the same worker pool on the same backend model. None of that is a human's
279
- * model choice — which providers currently hold a valid key is an ops detail
280
- * (today: deepseek/anthropic/groq; openai and gemini drift in and out), and
281
- * surfacing it invites picking a provider that happens to be dead right now.
282
- * The pool already auto-routes across whichever providers are live, so the
283
- * desktop dropdown offers exactly one entry for the "aegis" class: the
284
- * collapsed "Nexus" brain — never the raw provider list.
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 selection rule itself now lives in `client/brain-catalog.js`, shared with
287
- * the CLI: this host and the terminal were offering the same account two
288
- * different model lists (one collapsed entry vs. every distinct provider id),
289
- * and two copies of the rule is precisely how that drift happened. Resolved the
290
- * two ways this repo resolves every shared module — repo-relative in a
291
- * checkout, and this app's staged vendor/ tree — with the selection itself
292
- * unchanged.
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
- providers = (data && data.providers) || [];
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.9",
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(modelMeta.get(els.modelSelect.value));
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
@@ -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
+ };
@@ -1,9 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * The shared `~/.aegiscode/.env` loader.
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
- * Two rules this module deliberately obeys:
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. It reads only. Nothing here writes a secret, so the 0600 store remains
27
- * the one thing this repo creates; if the file the user made is readable
28
- * by other accounts we say so rather than fixing it silently.
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 quoted = (value.startsWith('"') && value.endsWith('"') && value.length >= 2)
102
- || (value.startsWith("'") && value.endsWith("'") && value.length >= 2);
103
- if (quoted) {
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
  };