aegis-desktop 0.7.6 → 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
+ };