aegis-desktop 0.7.7 → 0.7.8

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/README.md CHANGED
@@ -5,26 +5,95 @@ with an **agentic tool loop**: the model can read, write, and edit files,
5
5
  list directories, glob, grep, run shell commands in a persistent session, and
6
6
  delegate whole sub-tasks to subagents. It does **not** require Claude Code.
7
7
 
8
+ ## Install
9
+
10
+ Download a build from the
11
+ [releases page](https://github.com/aegiscloud/aegiscode-desktop/releases), or
12
+ install from npm:
13
+
8
14
  ```bash
9
- npm install -g aegis-desktop
10
- aegis
15
+ npm install -g aegis-desktop # requires Node 18+
16
+ aegis # launch
11
17
  ```
12
18
 
19
+ Either way, on first launch:
20
+
21
+ 1. Open **Settings** in the sidebar.
22
+ 2. Paste an AEGIS key into the **API key** row — get a free one at
23
+ <https://aegiscloud.org>. `AEGIS_API_KEY` is picked up from the environment
24
+ if you would rather not paste it.
25
+ 3. Pick a model class from the picker at the bottom of the composer.
26
+
27
+ The published package is [`aegis-desktop`](https://www.npmjs.com/package/aegis-desktop)
28
+ on npm; this repo is its source. No AEGIS account is needed for the **Ollama**
29
+ class or a **local** custom endpoint — every other class bills your AEGIS
30
+ account, either the pooled margin or the BYOK handling fee.
31
+
32
+ ## Using it
33
+
34
+ Everything is in the window — there is no slash-command line to learn. Plain
35
+ text in the composer is a prompt.
36
+
37
+ | Where | What it does |
38
+ |---|---|
39
+ | **Composer** | Type and press `Enter`. `Shift+Enter` for a newline. |
40
+ | **Class picker** | Bottom of the composer — switches the route mid-conversation, context intact. |
41
+ | **Model dropdown** | Next to it — pins a model id for the selected class, or leaves it on "server default (auto)". |
42
+ | **Settings** (sidebar) | API key, provider keys, tool-confirmation toggle. |
43
+ | **Queue card** (sidebar) | The unattended work queue — same file the CLI drains. |
44
+ | **Quick Launcher card** | Enable/rebind the global hotkey. |
45
+ | **remember** (on any reply) | Pin that message to cross-machine cloud memory. |
46
+
47
+ Approval cards appear inline before `exec`, `writeFile` or `editFile` runs:
48
+ **Allow once**, **Allow for this session**, or **Deny**. Turn them off entirely
49
+ with **Settings → "Confirm before running tools"** — on by default.
50
+
13
51
  ## Model classes
14
52
 
15
- Pick any of four transports from the model-class picker, switchable
53
+ Pick any of five transports from the model-class picker, switchable
16
54
  mid-conversation with context intact:
17
55
 
18
- | Class | Transport | Key held in |
19
- |---|---|---|
20
- | **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process |
21
- | **Ollama** | local `ollama` daemon | no key needed |
22
- | **Custom OpenAI-compatible** (LM Studio, OpenRouter, vLLM, …) | direct from the desktop app | main process — never sent to the renderer |
23
- | **Anthropic-compatible** (Claude, or any Messages-format gateway) | direct from the desktop app | main process |
24
-
25
- Get a free AEGIS key at **https://aegiscloud.org**, or use your own
26
- Ollama/OpenAI-compatible/Anthropic-compatible endpoint — no AEGIS account
27
- needed for those.
56
+ | Class | Transport | Key held in | Billed? |
57
+ |---|---|---|---|
58
+ | **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process | yes — pooled margin |
59
+ | **Ollama** | local `ollama` daemon | no key needed | **no** — nothing leaves the machine |
60
+ | **Custom OpenAI-compatible** (LM Studio, vLLM, llama.cpp, an Ollama shim) | direct from the desktop app, **local base URLs only** | main process — never sent to the renderer | **no** — free lane, local only |
61
+ | **Anthropic-compatible** (a local Messages-format gateway — LiteLLM, claude-code-router) | direct from the desktop app, **local base URLs only** | main process | **no** — free lane, local only |
62
+ | **Bring your own key** | your provider key, relayed by AEGIS — see below | main process | yes — flat AEGIS handling fee |
63
+
64
+ Get a free AEGIS key at **https://aegiscloud.org**. The two custom classes are
65
+ free because aegiscode talks straight to an endpoint on your own machine; point
66
+ one at a hosted provider instead and there is nothing for AEGIS to meter, so
67
+ those models live on the **Bring your own key** class, which does bill.
68
+
69
+ ### Bring your own key (BYOK)
70
+
71
+ For the providers AEGIS does **not** run in its pool — bring your own key and
72
+ the models that key unlocks appear as their own entries, one per provider.
73
+
74
+ 1. **Settings** → find the row named `BYOK: <Provider>` (OpenAI, Anthropic,
75
+ DeepSeek, Groq, xAI, Mistral, Gemini, OpenRouter, …).
76
+ 2. Paste your provider key and **Save**. There is no base-URL field on these
77
+ rows: a BYOK turn always talks to AEGIS's own relay
78
+ (`/api/v1/byok/chat/completions`), which is what attaches your AEGIS key.
79
+ 3. Select the **Bring your own key** class and pick a model.
80
+
81
+ **What it costs.** AEGIS pays your provider nothing on this lane, so there is no
82
+ provider cost to take a margin on — instead your AEGIS account is charged a flat
83
+ **handling fee** per 1k tokens, for the routing, prompt assembly, caching, tool
84
+ bridging and uptime that still happen server-side. The rate is the server's own
85
+ (published on `GET /api/v1/byok/providers`) and is shown in Settings directly
86
+ under the provider rows; it is never hardcoded here, so it cannot drift from the
87
+ ledger that bills you. It is deliberately below the pooled price for the same
88
+ traffic — BYOK stays the cheaper lane, it just is not the free one.
89
+
90
+ **Two keys are needed.** Your provider key *and* an AEGIS account key: the
91
+ handling fee has to be billed somewhere. With no AEGIS key connected the class
92
+ shows every model but says exactly that, rather than failing opaquely.
93
+
94
+ Note that a **BYOK turn is single-shot** — the relay takes no `tools` parameter,
95
+ so the agentic tool loop below is off for these models. Use a pooled or direct
96
+ class when you want file and shell access.
28
97
 
29
98
  ## Tools available to the model
30
99
 
@@ -180,7 +249,16 @@ The app registers an `aegis://` protocol handler:
180
249
  ## Run from source
181
250
 
182
251
  ```bash
183
- cd desktop
252
+ git clone https://github.com/aegiscloud/aegiscode-desktop.git
253
+ cd aegiscode-desktop
254
+ npm install
255
+ npm start
256
+ ```
257
+
258
+ Or, from the monorepo checkout, run this directory directly:
259
+
260
+ ```bash
261
+ cd aegiscode-plugin/desktop
184
262
  npm install
185
263
  npm start
186
264
  ```
@@ -217,6 +295,14 @@ bin/aegis.js `aegis` CLI entry point for the global npm install
217
295
  This directory is part of the [aegiscode-plugin](../README.md) monorepo,
218
296
  which also ships a Claude Code plugin and the shared `client/aegis.js`
219
297
  transport over the same AEGIS backend — see the repo root for that fuller
220
- architecture picture. A read-only mirror of just this directory (for
221
- browsing or `git clone`) lives at
222
- [aegiscloud/aegiscode-desktop](https://github.com/aegiscloud/aegiscode-desktop).
298
+ architecture picture, and [cli/README.md](../cli/README.md) for the terminal
299
+ host over the same engine.
300
+
301
+ **Two repos, one product.** This directory is the source of truth. The
302
+ standalone repo at
303
+ [aegiscloud/aegiscode-desktop](https://github.com/aegiscloud/aegiscode-desktop)
304
+ is a `git subtree split` of it — same code, published separately so it can be
305
+ cloned and built on its own. Edits land here and are synced out; nothing is
306
+ authored there. The npm package
307
+ [`aegis-desktop`](https://www.npmjs.com/package/aegis-desktop) is built from
308
+ that standalone repo.
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The direct-dial policy: which base URLs may reach a provider transport, and
5
+ * why that is the only free lane left.
6
+ *
7
+ * Both hosts that can talk to a user-supplied endpoint — the desktop app
8
+ * (settings pane → 'openai-compat' / 'anthropic' classes) and the CLI
9
+ * (src/engine.js prepareCustom, the `/model add` catalog) — share THIS file
10
+ * rather than a copy each. The CLI vendors `desktop/lib/local/` wholesale and
11
+ * resolves this module through src/sharedpaths.js, so the two hosts cannot
12
+ * drift apart about what "local" means or which error a remote URL gets. A
13
+ * second implementation is how one host would keep billing correctly while the
14
+ * other quietly served unpaid traffic.
15
+ *
16
+ * The rule: every lane that can bill does. `aegis` is the pooled route (the
17
+ * account key is attached and the pool takes its margin); `byok` is the relay
18
+ * (`services/pricing.price_byok_call` → `token_bank.charge_byok`, the AEGIS
19
+ * handling fee, charged against the account resolved from `X-AEGIS-Key`). The
20
+ * provider transports in providers.js bill NOTHING — they are a direct dial to
21
+ * whoever owns the URL — so the only usage they may carry is an endpoint on
22
+ * this machine, where there is no vendor to pay in the first place.
23
+ *
24
+ * That is why a remote base URL is refused rather than metered: the relay
25
+ * accepts a fixed catalog of provider ids (`services/nexus_provider/catalog.py`,
26
+ * upstream key read from `X-Provider-Key`), so an arbitrary remote URL has
27
+ * nothing to be billed against even if a client wanted to invoice it. Remote
28
+ * providers belong on BYOK, which bills. There is deliberately no flag or env
29
+ * var that re-opens the direct lane for a remote URL, because such a flag would
30
+ * be a billing bypass.
31
+ *
32
+ * Where the gate lives — the seams, not the transport:
33
+ * · desktop/lib/settings.js `set()` refuses to STORE a remote base URL, so
34
+ * the unusable configuration cannot be created.
35
+ * · desktop/lib/local/engine.js `chat()` refuses to DIAL one before any
36
+ * transport call (a row hand-edited into the
37
+ * settings file, or written before this policy
38
+ * existed, still cannot be used).
39
+ * · cli/src/custommodels.js refuses one at `/model add` and again at
40
+ * dispatch, for the same two reasons.
41
+ * providers.js itself stays policy-free: it is a wire-format function, and
42
+ * pinning the policy there would make it unreachable for the tests that check
43
+ * URL building. Same split the BYOK balance gate uses — the relay owns the
44
+ * decision, the client refuses to send.
45
+ */
46
+
47
+ /** Host of a URL, lower-cased, or '' if it does not parse. */
48
+ function hostOf(url) {
49
+ try {
50
+ return new URL(String(url)).host.toLowerCase();
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Whether a base URL addresses an endpoint on this machine — the gate that
58
+ * decides whether the free direct lane is available at all.
59
+ *
60
+ * True for: loopback (localhost, `*.localhost`, 127.0.0.0/8, `::1`), RFC1918
61
+ * private ranges (10/8, 172.16/12, 192.168/16), IPv6 unique-local fc00::/7,
62
+ * link-local (169.254/16, fe80::/10), the reserved local suffixes `.local`,
63
+ * `.internal`, `.lan`, and a bare dotless hostname (`ollama`, `gpu-box` — it
64
+ * can only resolve through this machine's own resolver).
65
+ *
66
+ * FAIL CLOSED: anything that does not parse, carries no host, is a public
67
+ * address or name, or is merely ambiguous is NOT local. That direction is the
68
+ * whole point — a false "local" is an unpaid turn, a false "remote" is a
69
+ * refusal the user can fix by pointing at a local address or by moving to the
70
+ * billed lane, which is the outcome we want in a tie.
71
+ */
72
+ function isLocalEndpoint(url) {
73
+ let host = '';
74
+ let scheme = '';
75
+ try {
76
+ const parsed = new URL(String(url == null ? '' : url));
77
+ host = parsed.hostname.toLowerCase();
78
+ scheme = parsed.protocol.toLowerCase();
79
+ } catch {
80
+ return false;
81
+ }
82
+ // Only a base URL a transport can actually dial. The transports in
83
+ // providers.js append '/chat/completions' and fetch it, so anything that is
84
+ // not http(s) — or has no scheme at all — is not an endpoint, and is refused
85
+ // by the same fail-closed rule as an unparseable value. Checked before the
86
+ // host, so `ftp://box.local` cannot ride the local suffix to a pass.
87
+ if (scheme !== 'http:' && scheme !== 'https:') return false;
88
+ if (!host) return false;
89
+ // WHATWG keeps the brackets on an IPv6 hostname; strip them so one spelling
90
+ // covers both `[::1]` and a bare `::1`.
91
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
92
+ if (!host || /[\s/\\@]/.test(host)) return false;
93
+
94
+ // Loopback by name, plus the reserved local suffixes. `.local`/`.internal`/
95
+ // `.lan` are the mDNS / split-DNS names an on-box service answers to. The
96
+ // suffix must END the name: `example.local.evil.com` is somebody else's host.
97
+ if (host === 'localhost' || host.endsWith('.localhost')) return true;
98
+ if (/\.(local|internal|lan)$/.test(host)) return true;
99
+
100
+ // IPv6 literals.
101
+ if (host.includes(':')) {
102
+ if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true; // loopback
103
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return true; // fc00::/7 unique-local
104
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return true; // fe80::/10 link-local
105
+ return false;
106
+ }
107
+
108
+ // IPv4 literals.
109
+ const quad = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
110
+ if (quad) {
111
+ const o = quad.slice(1).map(Number);
112
+ if (o.some((n) => n > 255)) return false; // not an address, and not local
113
+ const [a, b] = o;
114
+ if (a === 127) return true; // 127.0.0.0/8 loopback
115
+ if (a === 10) return true; // 10.0.0.0/8
116
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
117
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
118
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
119
+ return false; // a real, routable address — remote, and billed
120
+ }
121
+
122
+ // A bare, dotless hostname is local by convention (`ollama`, `llama-box`):
123
+ // it can only resolve through this machine's own resolver or /etc/hosts.
124
+ // Anything with a dot is a DNS name for somebody else's machine.
125
+ return !host.includes('.');
126
+ }
127
+
128
+ /** What IS allowed here, in one sentence, shared by both hosts' refusals. */
129
+ const ALLOWED_HINT =
130
+ 'Allowed: localhost, 127.0.0.1, a private/LAN address (10.x, 172.16-31.x, 192.168.x), ' +
131
+ '*.local/.internal/.lan, or a dotless host like "ollama".';
132
+
133
+ /**
134
+ * The refusal a non-local base URL gets, in the words the user needs in order
135
+ * to act: it names the offending URL, the kind of address that IS allowed, and
136
+ * the lane that replaces this one. `hint` is the host-specific half —
137
+ * `/class byok` in the terminal, the Bring-your-own-key Settings row in the
138
+ * GUI — while the policy half is shared, so the two hosts cannot describe the
139
+ * same rule differently.
140
+ */
141
+ function remoteRefusal(baseURL, { subject = 'custom endpoints', hint = '' } = {}) {
142
+ const where = String(baseURL == null ? '' : baseURL);
143
+ const tail = hint || 'Remote providers are billed, so use the lane that bills them, or point this at a local address.';
144
+ return `${subject} must be LOCAL — remote base URL ${JSON.stringify(where)} is not offered on this lane. ` +
145
+ `${ALLOWED_HINT} ${tail}`;
146
+ }
147
+
148
+ /**
149
+ * Throw the refusal (as a 400-class error every caller already paints) unless
150
+ * the base URL is local. The one-line form of the gate for the dispatch and
151
+ * storage seams, so neither has to remember the status code.
152
+ *
153
+ * An EMPTY base URL is refused here like any other non-local value, because
154
+ * there is no endpoint to dial. The storage seam does not use this function
155
+ * directly for that reason — see allowsDirectDialRow below, which treats "no
156
+ * URL configured" as "nothing to gate".
157
+ */
158
+ function ensureLocalEndpoint(baseURL, opts) {
159
+ if (isLocalEndpoint(baseURL)) return true;
160
+ const err = new Error(remoteRefusal(baseURL, opts));
161
+ err.status = 400;
162
+ err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
163
+ throw err;
164
+ }
165
+
166
+ /**
167
+ * The settings-store rows whose base URL is dialled DIRECTLY by the transports
168
+ * in providers.js — i.e. the rows the gate above exists for.
169
+ *
170
+ * 'openai-compat', 'anthropic' the desktop's two custom endpoints (its
171
+ * Settings pane names them exactly so, and
172
+ * engine.js's CUSTOM_CLASSES reads the row
173
+ * under the class name).
174
+ * 'custom:*' the CLI's per-model namespace (src/
175
+ * custommodels.js). The CLI keeps the base URL
176
+ * in config.json `customModels` and stores only
177
+ * the KEY here, so this prefix is a second lock
178
+ * on a door that is already shut — deliberate:
179
+ * a future writer that puts a URL in this row
180
+ * must not silently reopen the lane.
181
+ *
182
+ * It matters that this is a NAMED list and not "every row". The same store
183
+ * holds the billed lanes' rows, and those legitimately carry remote values or
184
+ * nothing at all:
185
+ * · `byok:<provider>` — always remote by definition (the provider's own API,
186
+ * reached through AEGIS's relay, which is what bills it). The desktop's
187
+ * Settings pane saves those rows with baseURL '' (the relay owns the URL),
188
+ * and a blanket "must be local" rule would refuse even the empty string,
189
+ * breaking BYOK configuration entirely.
190
+ * · `aegis` — reserved namespace, never touched through this surface.
191
+ * So a gate keyed on the value alone is wrong; it is keyed on the ROW.
192
+ */
193
+ const DIRECT_DIAL_ROWS = Object.freeze(['openai-compat', 'anthropic']);
194
+ const DIRECT_DIAL_PREFIXES = Object.freeze(['custom:']);
195
+
196
+ /** Whether a settings-store row's base URL is dialled directly (and so gated). */
197
+ function isDirectDialRow(provider) {
198
+ const p = String(provider == null ? '' : provider);
199
+ if (DIRECT_DIAL_ROWS.includes(p)) return true;
200
+ return DIRECT_DIAL_PREFIXES.some((pre) => p.startsWith(pre));
201
+ }
202
+
203
+ /**
204
+ * Whether a row may STORE this base URL — the storage seam's question, which is
205
+ * not quite the dispatch seam's.
206
+ *
207
+ * A direct-dial row with no URL is fine to store: that is how a row is cleared,
208
+ * and how it looks before it is configured (`customStatus` reports it as
209
+ * unconfigured, so nothing dials it). What must never be stored is a NON-EMPTY
210
+ * remote URL, which would make the row look configured and usable.
211
+ */
212
+ function allowsDirectDialRow(provider, baseURL) {
213
+ if (!isDirectDialRow(provider)) return true;
214
+ const url = String(baseURL == null ? '' : baseURL).trim();
215
+ if (!url) return true; // clearing the row, or a not-yet-configured one
216
+ return isLocalEndpoint(url);
217
+ }
218
+
219
+ module.exports = {
220
+ hostOf,
221
+ isLocalEndpoint,
222
+ remoteRefusal,
223
+ ensureLocalEndpoint,
224
+ isDirectDialRow,
225
+ allowsDirectDialRow,
226
+ ALLOWED_HINT,
227
+ DIRECT_DIAL_ROWS,
228
+ };
@@ -45,6 +45,10 @@ const os = require('node:os');
45
45
 
46
46
  const toolsModule = require('./tools.js');
47
47
  const promptModule = require('./prompt.js');
48
+ // The direct-dial policy: which base URLs may reach a provider transport, and
49
+ // why a remote one is refused rather than metered. The same module the CLI
50
+ // resolves (cli/src/custommodels.js), so both hosts answer with one rule.
51
+ const { isLocalEndpoint, remoteRefusal } = require('./endpoints.js');
48
52
  const { ShellSession } = require('./shell.js');
49
53
  const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
50
54
  // Cooperative working-tree sharing (see each module's header). The lock
@@ -657,14 +661,26 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
657
661
  * a base URL is mandatory for both, and Anthropic additionally needs its own
658
662
  * key (the wire format authenticates with x-api-key). Reporting them as
659
663
  * always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
664
+ *
665
+ * A stored REMOTE base URL is reported as not-configured rather than ready,
666
+ * plus `blocked` and the reason. This is the third face of the direct-dial
667
+ * gate (storage in settings.js set, dispatch in chat below): a row that
668
+ * predates the rule must not be OFFERED either, or the class list advertises
669
+ * a lane the turn then refuses. The reason travels so the UI can explain it
670
+ * instead of showing a dead row — and it is recoverable by design: saving a
671
+ * local URL (or clearing the field) makes the class usable again, which is
672
+ * what the refusal text tells the user to do.
660
673
  */
661
674
  function customStatus(cls) {
662
675
  const cfg = settings.get(cls) || {};
663
676
  const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
664
677
  const hasBase = Boolean(baseURL);
665
678
  const hasKey = Boolean(cfg.configured);
679
+ const blocked = hasBase && !isLocalEndpoint(baseURL);
666
680
  return {
667
- configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
681
+ configured: !blocked && (cls === 'anthropic' ? hasBase && hasKey : hasBase),
682
+ blocked,
683
+ ...(blocked ? { blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) } : {}),
668
684
  baseURL,
669
685
  keyMask: cfg.keyMask || null,
670
686
  };
@@ -714,14 +730,27 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
714
730
  if (cls === 'byok') {
715
731
  // The server's catalog names every provider it accepts a key for, the
716
732
  // models each unlocks, and whether an AEGIS account key is even needed
717
- // to ask (it is not — see byokProviders' own docstring). Deliberately
718
- // NOT gated on aegis.apiKey the way the pooled class above is: BYOK's
719
- // whole point is a caller who brings their own credential, and the
720
- // catalog itself answers to an anonymous request.
733
+ // to ask (it is not — the catalog answers an anonymous request).
734
+ // Deliberately NOT gated on aegis.apiKey the way the pooled class above
735
+ // is: BYOK's whole point is a caller who brings their own credential, and
736
+ // hiding the catalog would hide the answer to "which key do I go and get"
737
+ // from exactly the person deciding whether to bother. Nothing here
738
+ // enforces billing either: the desktop is one client of a route that is
739
+ // unauthenticated by design, so refusing in this process would stop
740
+ // exactly one host out of many. The relay owns that decision, and reports
741
+ // it via `fee.require_balance` below.
721
742
  let providers = [];
743
+ let fee = null;
722
744
  try {
723
745
  const data = await aegis.byokProviders();
724
746
  providers = (data && data.providers) || [];
747
+ // The handling fee AEGIS adds on top of the caller's vendor bill. It is
748
+ // the server's own published rate (services/pricing.price_byok_call) and
749
+ // is passed through untouched — never re-derived here, because a client
750
+ // that hardcodes a fee is a client that can disagree with the ledger.
751
+ // Absent until the server publishes one, and the UI must then say
752
+ // nothing rather than show a guess.
753
+ fee = (data && data.fee) || null;
725
754
  } catch {
726
755
  providers = [];
727
756
  }
@@ -744,6 +773,8 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
744
773
  return {
745
774
  class: cls, models, providers,
746
775
  needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
776
+ needsAegisKey: !aegis.apiKey,
777
+ fee,
747
778
  };
748
779
  }
749
780
  // Custom endpoints: the model id is the *user's* choice — a provider model
@@ -755,7 +786,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
755
786
  // along for display only.
756
787
  const cfg = settings.get(cls) || {};
757
788
  const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
758
- return { class: cls, models: [], needsModelId: true, baseURL };
789
+ // Same reporting as customStatus: a stored remote URL is not a usable
790
+ // model list, and the reason has to reach the UI with it (see customStatus).
791
+ const blocked = Boolean(baseURL) && !isLocalEndpoint(baseURL);
792
+ return {
793
+ class: cls,
794
+ models: [],
795
+ needsModelId: true,
796
+ baseURL,
797
+ ...(blocked
798
+ ? { blocked: true, blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) }
799
+ : {}),
800
+ };
759
801
  }
760
802
 
761
803
  /**
@@ -941,18 +983,42 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
941
983
  // automatically by byokChatCompletion() as X-AEGIS-Key so the account
942
984
  // gets billed the handling fee; see client/aegis.js.
943
985
  const { provider, model: bareModel } = splitByokModel(opts.model);
944
- return aegis.byokChatCompletion({
945
- provider,
946
- model: bareModel,
947
- providerKey: opts.apiKey,
948
- prompt: opts.prompt,
949
- system: opts.system,
950
- messages: opts.messages,
951
- maxTokens: opts.maxTokens,
952
- stream: true,
953
- onStream: opts.onDelta,
954
- signal: opts.signal,
955
- });
986
+ try {
987
+ return await aegis.byokChatCompletion({
988
+ provider,
989
+ model: bareModel,
990
+ providerKey: opts.apiKey,
991
+ prompt: opts.prompt,
992
+ system: opts.system,
993
+ messages: opts.messages,
994
+ maxTokens: opts.maxTokens,
995
+ stream: true,
996
+ onStream: opts.onDelta,
997
+ signal: opts.signal,
998
+ });
999
+ } catch (e) {
1000
+ // The relay's own balance gate (aegis1 `AEGIS_BYOK_REQUIRE_BALANCE`,
1001
+ // app.py `byok_chat_completions`) answers 402 with a body aimed at an
1002
+ // API consumer — "Insufficient balance. Top up to continue using
1003
+ // BYOK." Shown raw in a chat transcript that reads as a crash rather
1004
+ // than a bill, so the one thing the user has to DO is said here, in the
1005
+ // hosts' own voice. The status is preserved so every existing caller
1006
+ // (the CLI's error painter, the desktop turn guard) still sees a 402.
1007
+ //
1008
+ // The provider key is untouched by this: it is the caller's own and it
1009
+ // is still valid. What ran out is the AEGIS balance the handling fee
1010
+ // is billed against, which is the whole reason this lane has a fee.
1011
+ if (e && e.status === 402) {
1012
+ const err = new Error(
1013
+ 'byok: this account has no AEGIS balance left, and the BYOK handling fee is ' +
1014
+ 'billed there — your provider key is still valid, this is not a key problem. ' +
1015
+ 'Top up the account, then try again (or /class aegis to use the pooled lane).'
1016
+ );
1017
+ err.status = 402;
1018
+ throw err;
1019
+ }
1020
+ throw e;
1021
+ }
956
1022
  }
957
1023
 
958
1024
  const common = {
@@ -1093,6 +1159,60 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1093
1159
  err.status = 400;
1094
1160
  throw err;
1095
1161
  }
1162
+ // …and the AEGIS account key is what makes the turn BILLABLE at all. The
1163
+ // relay authenticates on the provider key and resolves the payer
1164
+ // separately, from X-AEGIS-Key (app.py `_byok_identify_user`): with no
1165
+ // account key the server can attribute the handling fee to no one, so it
1166
+ // is logged against user 0 as uncollected — an anonymous free ride the
1167
+ // fee exists to close. Require the account key here so EVERY BYOK turn is
1168
+ // attributed and every caller pays the handling fee: a funded balance is
1169
+ // debited immediately, an unfunded one records the fee as owed
1170
+ // (token_bank.charge_byok clamps to zero and never refuses), and nobody
1171
+ // is served free. Balance is the server's own concern, not this gate's —
1172
+ // with a balance or without one, the account still pays. `listModels`
1173
+ // still answers anonymously (the catalog is how a user finds out which
1174
+ // key to get), so this refuses only the send, and only until a key lands.
1175
+ if (cls === 'byok' && !aegis.apiKey) {
1176
+ const err = new Error(
1177
+ 'byok: connect your AEGIS account key first — the BYOK handling fee is billed there. ' +
1178
+ 'Add it in the Status card (or run /login), then try again.'
1179
+ );
1180
+ err.status = 401;
1181
+ throw err;
1182
+ }
1183
+
1184
+ // THE DIRECT-DIAL GATE. A custom class talks to the user's base URL
1185
+ // itself (providers.js openaiCompatible / anthropicMessages), and that
1186
+ // transport bills NOBODY: no pooled margin, no BYOK handling fee, no
1187
+ // account key attached. The only usage it may therefore carry is an
1188
+ // endpoint on this machine, where there is no vendor to pay. A remote URL
1189
+ // here is an unpaid turn, and it is refused rather than metered because
1190
+ // there is nothing to meter it against — the relay accepts a fixed
1191
+ // catalog of provider ids (services/nexus_provider/catalog.py), so an
1192
+ // arbitrary remote URL cannot be billed there either.
1193
+ //
1194
+ // Checked at DISPATCH and not only at storage (settings.js set refuses
1195
+ // the same URL): a row written before this rule existed, or hand-edited
1196
+ // into settings.json, is still in the file, and reading it back happily
1197
+ // would keep the lane open. Both seams, one rule — the message is
1198
+ // endpoints.js's, so the desktop and the CLI say the same thing.
1199
+ if (CUSTOM_CLASSES.includes(cls)) {
1200
+ const raw = cfg && typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
1201
+ if (raw && !isLocalEndpoint(raw)) {
1202
+ const err = new Error(
1203
+ remoteRefusal(raw, {
1204
+ subject: `the ${cls} endpoint`,
1205
+ hint:
1206
+ 'This class dials your URL directly and bills nobody, so it is local-only. ' +
1207
+ 'Use a model on this machine, or move the provider to the BYOK card (/class byok), ' +
1208
+ 'which relays through AEGIS and charges the handling fee.',
1209
+ })
1210
+ );
1211
+ err.status = 400;
1212
+ err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
1213
+ throw err;
1214
+ }
1215
+ }
1096
1216
 
1097
1217
  // Custom classes carry no enumerable model list (see listModels), so a
1098
1218
  // blank id here means the user never typed one. Fail loudly in-process
package/lib/settings.js CHANGED
@@ -17,6 +17,12 @@
17
17
 
18
18
  const fs = require('node:fs');
19
19
  const path = require('node:path');
20
+ // The direct-dial policy (see that file): which rows may hold a remote base URL.
21
+ // Enforced HERE, at the storage seam, so the unusable configuration cannot be
22
+ // created by either host — the desktop's Settings pane or the CLI's `/model add`
23
+ // — rather than only being refused at send time. Pure node builtins, so this
24
+ // store stays loadable without Electron.
25
+ const { isLocalEndpoint, remoteRefusal, isDirectDialRow } = require('./local/endpoints.js');
20
26
 
21
27
  const SETTINGS_FILE = 'settings.json';
22
28
 
@@ -124,8 +130,13 @@ function createSettingsStore({ dir, safeStorage } = {}) {
124
130
  function save(data) {
125
131
  fs.mkdirSync(path.dirname(file), { recursive: true });
126
132
  const tmp = `${file}.tmp-${process.pid}`;
127
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
133
+ // 0600, written before the rename so the file is never briefly readable:
134
+ // this store holds provider keys, and both hosts are now able to write it
135
+ // (the CLI uses the same file, without Electron's safeStorage, so at-rest
136
+ // base64 is all the protection there is — file mode is the real control).
137
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
128
138
  fs.renameSync(tmp, file);
139
+ try { fs.chmodSync(file, 0o600); } catch {}
129
140
  }
130
141
 
131
142
  /** Provider CRUD must never touch a reserved (AEGIS-key) namespace. */
@@ -149,8 +160,38 @@ function createSettingsStore({ dir, safeStorage } = {}) {
149
160
  };
150
161
  }
151
162
 
163
+ /**
164
+ * Write a provider row.
165
+ *
166
+ * A direct-dial row ('openai-compat', 'anthropic', 'custom:*') may not hold a
167
+ * remote base URL: those classes talk to the URL themselves (providers.js),
168
+ * which bills nobody, so the only usage they may carry is an endpoint on this
169
+ * machine. Refusing at STORAGE — the earliest seam, shared by the desktop's
170
+ * Settings pane and the CLI — means a remote custom endpoint cannot be
171
+ * configured into existence in the first place; the engine's dispatch gate
172
+ * then covers the rows that predate this rule or were hand-edited into the
173
+ * file. Empty is allowed (that is how the row is cleared, and how a
174
+ * not-yet-configured row looks), and every other namespace is untouched:
175
+ * `byok:<provider>` rows are remote by definition and reach their provider
176
+ * through AEGIS's relay, which is what bills them.
177
+ */
152
178
  function set(provider, { baseURL, key } = {}) {
153
179
  assertNotReserved(provider);
180
+ if (baseURL !== undefined && isDirectDialRow(provider)) {
181
+ const next = String(baseURL == null ? '' : baseURL).trim();
182
+ if (next && !isLocalEndpoint(next)) {
183
+ const err = new Error(remoteRefusal(next, {
184
+ subject: `the ${provider} endpoint`,
185
+ hint:
186
+ 'This lane is for a model running on this machine. To use a remote provider, ' +
187
+ 'save it under Bring-your-own-key (the CLI\'s /class byok), where the AEGIS handling ' +
188
+ 'fee bills the turn.',
189
+ }));
190
+ err.status = 400;
191
+ err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
192
+ throw err;
193
+ }
194
+ }
154
195
  const data = load();
155
196
  const cfg = data[provider] || {};
156
197
  if (baseURL !== undefined) cfg.baseURL = baseURL;