aegis-desktop 1.0.13 → 1.0.14

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.
@@ -25,10 +25,15 @@
25
25
  * account purely by Google identity, with no notion of "the account
26
26
  * this machine's API key already belongs to" — so connecting Drive
27
27
  * before signing in attaches the tokens to a DIFFERENT account than the
28
- * one this machine will poll, and the poll just times out. `connectDrive`
29
- * refuses outright when this machine has no key at all, but cannot
30
- * detect "has a key, but it is an anonymous one" — that failure still
31
- * surfaces, just later, as a timeout with the right wording.
28
+ * one this machine will poll, and the poll just times out.
29
+ *
30
+ * "Signed in with Google" is NOT "has an API key". Every install
31
+ * auto-provisions an anonymous device key, so a key always exists and an
32
+ * `!apiKey` guard never fires. The only honest source for the real
33
+ * question is the server's `google_linked` (users.google_id), carried on
34
+ * `/api/drive/token` and surfaced as `status().googleLinked` — so
35
+ * `connectDrive` refuses on that, and the renderer disables the button
36
+ * from the same flag.
32
37
  *
33
38
  * Electron-free and fully injected — same shape as `account-key.js` next to
34
39
  * it — so the whole flow runs under a test with a stub `aegis` client, a
@@ -50,6 +55,10 @@ function createGoogleConnect({
50
55
  let connectInFlight = null;
51
56
  let lastSyncAt = null;
52
57
  let lastSyncError = null;
58
+ // Did a Google round-trip complete in THIS process? That is first-hand
59
+ // evidence of a linked account and does not depend on the server reporting
60
+ // it — which older servers do not (see `status`).
61
+ let signedInViaGoogle = false;
53
62
 
54
63
  /** Sign this machine in with Google, replacing whatever key it had. */
55
64
  function signInWithGoogle() {
@@ -88,6 +97,10 @@ function createGoogleConnect({
88
97
  /* best-effort — the account key itself is already saved */
89
98
  }
90
99
  }
100
+ // Before the notification, which is what makes the renderer re-read
101
+ // `status()` — otherwise that refresh races this assignment and
102
+ // reports the sign-in as not having happened.
103
+ signedInViaGoogle = true;
91
104
  if (typeof onAccountChange === 'function') onAccountChange();
92
105
  return { ok: true, opened, url: session.url, username: result.username || null };
93
106
  } finally {
@@ -99,16 +112,41 @@ function createGoogleConnect({
99
112
  }
100
113
 
101
114
  /** Open `/connect/drive` and wait for this account to show connected. */
102
- function connectDrive() {
115
+ async function connectDrive() {
103
116
  if (!aegis.apiKey) {
104
- return Promise.resolve({
117
+ return {
105
118
  ok: false,
106
119
  error: 'sign in with Google first — this device has no AEGIS account key yet',
107
- });
120
+ };
108
121
  }
109
122
  if (!connectInFlight) {
110
123
  connectInFlight = (async () => {
111
124
  try {
125
+ // The key exists but may be an anonymous device key. Ask the server
126
+ // whether this account is actually tied to a Google identity before
127
+ // opening a consent screen that would attach the tokens elsewhere.
128
+ // Inside the in-flight promise so a double click cannot open two
129
+ // consent tabs. If the status call fails, fall through: a transport
130
+ // blip must not be reported as "not signed in with Google".
131
+ let linked = true;
132
+ try {
133
+ const st = await driveConnect.driveStatus(aegis);
134
+ // Refuse only on a definite "not linked". A server that predates
135
+ // `google_linked` answers the same shape as an unlinked account,
136
+ // and refusing on that would make a real Google sign-in unable to
137
+ // ever connect Drive — the exact dead end this guard exists to
138
+ // prevent, only inverted.
139
+ linked = !(st.googleLinkedKnown && !st.googleLinked);
140
+ } catch {
141
+ /* unknown — let the flow try rather than misreport the reason */
142
+ }
143
+ if (!linked) {
144
+ return {
145
+ ok: false,
146
+ error: 'sign in with Google first — this account is not linked to a Google identity yet, '
147
+ + 'and connecting Drive now would attach it to a different account',
148
+ };
149
+ }
112
150
  const url = driveConnect.driveConnectUrl(aegis.apiBase);
113
151
  const opened = Boolean(openUrl(url));
114
152
  try {
@@ -125,11 +163,31 @@ function createGoogleConnect({
125
163
  return connectInFlight;
126
164
  }
127
165
 
128
- /** A one-shot read: signed in, and if so, is Drive connected. */
166
+ /**
167
+ * A one-shot read: signed in with Google, and if so, is Drive connected.
168
+ *
169
+ * `signedIn` is the server's `google_linked`, not "has an API key" — see
170
+ * the header. The old `apiKey ? true` form reported every anonymous
171
+ * auto-provisioned device as signed in, which both mislabelled the card
172
+ * ("signed in · Drive not connected yet" on a machine that never touched
173
+ * Google) and enabled Connect Drive ahead of the sign-in it depends on.
174
+ */
129
175
  async function status() {
130
- if (!aegis.apiKey) return { signedIn: false, connected: false, email: null };
176
+ if (!aegis.apiKey) {
177
+ return { signedIn: false, googleLinked: false, googleLinkedKnown: true, connected: false, email: null };
178
+ }
131
179
  const s = await driveConnect.driveStatus(aegis);
132
- return { signedIn: true, ...s };
180
+ // A completed round-trip outranks a server that cannot answer: without
181
+ // this, signing in against a server that predates `google_linked` leaves
182
+ // the card reading "not signed in" forever, because the sign-in that just
183
+ // succeeded is the one thing the flag was supposed to prove.
184
+ const signedIn = s.googleLinked === true || signedInViaGoogle;
185
+ return {
186
+ signedIn,
187
+ ...s,
188
+ googleLinked: s.googleLinked === true || signedIn,
189
+ googleLinkedKnown: s.googleLinkedKnown || signedIn,
190
+ };
133
191
  }
134
192
 
135
193
  /** Pull this account's AEGIS cloud memory and back it up to Drive. */
@@ -745,6 +745,7 @@ const { offerableCatalog } = requireSharedBrain();
745
745
  const { withByokAdditions } = requireSharedByokCatalog();
746
746
  const {
747
747
  withUserModels,
748
+ unmatchedUserProviders,
748
749
  load: loadUserModels,
749
750
  addUserModel,
750
751
  removeUserModel,
@@ -1671,6 +1672,16 @@ function createLocalEngine({
1671
1672
  // it via `fee.require_balance` below.
1672
1673
  let providers = [];
1673
1674
  let fee = null;
1675
+ // Did the CATALOG itself fail to arrive, as opposed to arriving empty?
1676
+ // The two are different states and the picker must not confuse them: a
1677
+ // 5xx / offline relay used to fall into the bare `catch` below and hand
1678
+ // back `providers: []`, which reads on screen exactly like an account
1679
+ // with nothing configured — while the user's provider row and their
1680
+ // `models.json` declaration sat intact on disk. Reported instead, the
1681
+ // same way the 'local' branch reports `listed: false` for a tag list
1682
+ // that did not arrive.
1683
+ let providersFailed = false;
1684
+ let providersError = null;
1674
1685
  try {
1675
1686
  const data = await aegis.byokProviders();
1676
1687
  // The server's catalog, plus the ids it does not name but the vendor
@@ -1688,8 +1699,16 @@ function createLocalEngine({
1688
1699
  // Absent until the server publishes one, and the UI must then say
1689
1700
  // nothing rather than show a guess.
1690
1701
  fee = (data && data.fee) || null;
1691
- } catch {
1702
+ } catch (err) {
1703
+ // Keep the empty list (nothing to offer) but SAY why, so the renderer
1704
+ // can distinguish "catalog unreachable" from "catalog said none". The
1705
+ // message is carried rather than a boolean alone because the two cases
1706
+ // need different copy: offline wants a retry, a server error wants the
1707
+ // status. `err.message` is the transport's own text, already shaped
1708
+ // elsewhere in this file.
1692
1709
  providers = [];
1710
+ providersFailed = true;
1711
+ providersError = (err && err.message) || String(err);
1693
1712
  }
1694
1713
  const models = [];
1695
1714
  for (const p of providers) {
@@ -1725,6 +1744,18 @@ function createLocalEngine({
1725
1744
  needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
1726
1745
  needsAegisKey: !aegis.apiKey,
1727
1746
  fee,
1747
+ // True only when the catalog did not arrive — never merely because it
1748
+ // arrived with no providers. `withUserModels` never fabricates a
1749
+ // provider, so this list is the same either side of the fold above.
1750
+ providersFailed,
1751
+ providersError,
1752
+ // Declarations in `models.json` naming a provider the catalog does not
1753
+ // list — a typo, or a provider renamed/dropped server-side. `withUserModels`
1754
+ // deliberately does NOT invent a row for them (it would 401 on its first
1755
+ // turn), but silence is the other wrong answer: the model stays in the
1756
+ // file and the user is entitled to know the picker cannot show it.
1757
+ // Carried as the ids alone; the renderer names them.
1758
+ unmatchedProviders: unmatchedUserProviders(providers),
1728
1759
  };
1729
1760
  }
1730
1761
  if (cls === 'local') {
@@ -2444,6 +2475,32 @@ function createLocalEngine({
2444
2475
  const refusal = localT.remoteRefusal(cfg.baseURL || localT.DEFAULT_BASE);
2445
2476
  if (refusal) throw refusal;
2446
2477
  }
2478
+ // `custom` is the mirror of `local`: both are rows whose payload is an
2479
+ // ADDRESS rather than a credential, and both are unusable without it.
2480
+ // They differ in what absence MEANS. A blank `local` row falls back to
2481
+ // DEFAULT_BASE (the stock Ollama port) because there is exactly one
2482
+ // sensible address to guess. A blank `custom` row has no default at all —
2483
+ // the catalog descriptor pins none, so the server refuses the call with a
2484
+ // bare 400 that says nothing about which half of the setup is missing.
2485
+ //
2486
+ // Caught here, before any byte goes out, so the message can name the
2487
+ // command that fixes it. Reachable in practice: `/byok-key custom <key>`
2488
+ // stores a key with no address, and the CLI explicitly trusts an
2489
+ // unreachable catalog rather than refusing the id — so a user who typed
2490
+ // the key first and the endpoint never has a row that looks configured.
2491
+ // Keyed on the provider, not the class: every other byok provider's host
2492
+ // is a server-side constant and needs no address from us.
2493
+ if (cls === 'byok' && byokParts.provider === 'custom' && !(cfg.baseURL || '').trim()) {
2494
+ const err = new Error(
2495
+ 'No endpoint saved for the "custom" provider — its key is on this machine but the address ' +
2496
+ 'it dials is not, and no default exists for a vendor AEGIS does not know. Fix: ' +
2497
+ '/model-add custom <https-base-url> <api-key> <model-id> (writes both halves), or the ' +
2498
+ 'endpoint field in Settings › Provider (custom).'
2499
+ );
2500
+ err.status = 400;
2501
+ err.code = 'custom_endpoint_missing';
2502
+ throw err;
2503
+ }
2447
2504
  const base = {
2448
2505
  cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
2449
2506
  // The turn's working directory, computed above as `turnCwd` — `dispatch`
@@ -3024,6 +3081,22 @@ function createLocalEngine({
3024
3081
  });
3025
3082
  }
3026
3083
  }
3084
+ } catch (e) {
3085
+ // Stamp the stop at the one place it is known for certain.
3086
+ //
3087
+ // `cancel(sessionId)` aborts THIS turn's controller, and the in-flight
3088
+ // transport unwinds as a rejection. The host has to tell that rejection
3089
+ // apart from a genuine provider/transport failure before it can stop
3090
+ // printing Ctrl-X as an application crash — and it used to guess from the
3091
+ // message text, which is exactly how a real failure ("connection aborted
3092
+ // by peer", "upstream aborted the connection") gets relabelled as a
3093
+ // deliberate stop and leaves the user with a silently empty answer. The
3094
+ // message is not evidence. This is: the signal reads aborted only because
3095
+ // the user asked for it, so it cannot be confused with anything upstream
3096
+ // did. Attached to the error object itself so it survives any rethrow,
3097
+ // and read by `isAbortShapedError` in desktop/main.js.
3098
+ if (signal.aborted && e && typeof e === 'object') e.aegisCancelled = true;
3099
+ throw e;
3027
3100
  } finally {
3028
3101
  if (shell) shell.dispose();
3029
3102
  controllers.delete(sessionId);
@@ -203,9 +203,15 @@ function byokFeeLine(fee) {
203
203
  // default-off gate after it. Joined with ' — ' only when there IS a second
204
204
  // clause: a sentence-ending dash with nothing after it ("…provider's bill —.")
205
205
  // is what the first live run of this line actually printed.
206
+ //
207
+ // This rate is the deployment's published RATE, not a quote for the reader
208
+ // specifically — an Unlimited subscriber is waived from this fee entirely
209
+ // (billing_service.is_unlimited on aegis1), so the number above is what a
210
+ // Free-tier BYOK caller pays, not what everyone pays.
206
211
  const price = `Bring your own key: relayed through AEGIS, handled at `
207
212
  + `${ratePerMillion(inRate)} in / ${ratePerMillion(outRate)} out, `
208
- + `on top of your provider's bill`;
213
+ + `on top of your provider's bill — no fee at all on Unlimited, `
214
+ + `and no fee at all during the free 1-month Unlimited trial`;
209
215
  let clause = '';
210
216
  if (f.require_account === true) {
211
217
  clause = 'and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed';