aegis-desktop 1.0.10 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/auto.js CHANGED
@@ -12,13 +12,17 @@
12
12
  * BACKGROUND. desktop/main.js and desktop/preload.js both stated an absolute
13
13
  * rule when the queue shipped: "NOTHING DRAINS UNLESS THE USER ASKED. There is
14
14
  * no interval, no drain at startup, and no drain-on-idle anywhere in this
15
- * file." That rule was right when the only trigger was a click, and it is still
16
- * the behaviour of a default install — but it made "queue a task and walk away"
17
- * impossible, which is the one thing an unattended queue is FOR. The user asked
18
- * for both automations explicitly (option A of that discussion), so the rule
19
- * becomes a DEFAULT rather than an absolute, in the only way that keeps the
20
- * original sentence true: an explicit, persisted, default-off switch, and a
21
- * tick that does nothing unless it is armed.
15
+ * file." A click was the only trigger, which made "queue a task and walk away"
16
+ * impossible — the one thing an unattended queue is FOR. The user asked for the
17
+ * automation (twice: "autonomous queue", then "work in the background
18
+ * instead"), so both switches now DEFAULT ON and the sidebar card that used to
19
+ * gate them is hidden: draining and sealing happen with nothing on screen.
20
+ *
21
+ * What keeps that safe is not an off-by-default — it is the guards below, which
22
+ * are unchanged and all of them skips rather than drains: an empty queue, a
23
+ * drain already in flight, another host holding the lock, and a switch turned
24
+ * off since the last tick (re-read EVERY tick, so off stops the very next one,
25
+ * whoever wrote it: the settings file, the CLI, a hand edit).
22
26
  *
23
27
  * There are exactly two automations, because there are exactly two acts in the
24
28
  * app that are irreversible-ish and cost something:
@@ -94,12 +94,43 @@ function slug(value, max = 80) {
94
94
  * @param {object} o
95
95
  * @param {string} o.dir the app's data directory (userData in a package)
96
96
  * @param {object} [o.env] process env, injectable for tests
97
+ * @param {() => string} [o.getVaultBackend] reads the live preference
98
+ * ('aegiscloud' | 'drive' | 'off', desktop/lib/settings.js's
99
+ * vaultBackendConfig) — a function, not a value, so a change made in
100
+ * Settings while the app is running reaches the very next vault call with
101
+ * no restart, the same contract getConfirmMode's gate already keeps.
97
102
  */
98
- function createFingerprintService({ dir, env } = {}) {
103
+ function createFingerprintService({ dir, env, getVaultBackend } = {}) {
99
104
  if (!dir) throw new Error('aegis-desktop: createFingerprintService needs a data dir');
100
105
  // One store: the shared modules read AEGIS_HOME and nothing else.
101
106
  const scope = { ...(env || process.env), AEGIS_HOME: (env || process.env).AEGIS_HOME || dir };
102
107
 
108
+ /** `true` only when the user has explicitly turned sync off — not when no
109
+ * preference has been recorded yet, which must keep resolving to whatever
110
+ * record-vault.js's own default is. */
111
+ function vaultDisabled() {
112
+ return typeof getVaultBackend === 'function' && getVaultBackend() === 'off';
113
+ }
114
+
115
+ /**
116
+ * `scope`, with the vault backend layered on top of it when a preference
117
+ * names one. 'aegiscloud' and an absent preference both fall through to
118
+ * `scope` unchanged — client/record-vault.js's own DEFAULT_BACKEND is
119
+ * already 'aegiscloud', so there is nothing to add for either case. 'drive'
120
+ * is the one preference that actually changes the destination: it asks the
121
+ * rclone adapter for the `gdrive` remote (the user's own Google Drive,
122
+ * configured by them — see client/record-vault.js's DEFAULT_REMOTE). 'off'
123
+ * is handled by `vaultDisabled()` before this is ever called, not here.
124
+ */
125
+ function vaultScope() {
126
+ if (typeof getVaultBackend === 'function' && getVaultBackend() === 'drive') {
127
+ return { ...scope, AEGIS_VAULT_BACKEND: 'rclone', AEGIS_VAULT_REMOTE: scope.AEGIS_VAULT_REMOTE || 'gdrive' };
128
+ }
129
+ return scope;
130
+ }
131
+
132
+ const VAULT_OFF_ERROR = 'the vault is turned off — turn it back on (Google Drive or AE) in Settings to publish or inspect it';
133
+
103
134
  const recordPath = (record, session) =>
104
135
  path.join(dir, 'fingerprints', `${slug(session)}.${String(record.createdAt).replace(/[:.]/g, '-')}.json`);
105
136
 
@@ -110,7 +141,22 @@ function createFingerprintService({ dir, env } = {}) {
110
141
  const { fp, authorship, vault } = load();
111
142
  const key = authorship.resolveKey({ argv: [], env: scope });
112
143
  const history = path.join(authorship.dataDir(scope), 'history.jsonl');
113
- const target = vault.vaultTarget(scope);
144
+ if (vaultDisabled()) {
145
+ return {
146
+ ok: true,
147
+ configured: Boolean(key.secret),
148
+ keySource: key.source,
149
+ keyPath: key.path,
150
+ keyFingerprint: key.secret ? fp.keyFingerprint(key.secret) : null,
151
+ publicKey: key.public,
152
+ defaultKeyPath: authorship.defaultKeyPath(scope),
153
+ historyPath: history,
154
+ historyPresent: fs.existsSync(history),
155
+ dataDir: authorship.dataDir(scope),
156
+ vault: { backend: 'off', remote: null, root: null, recordsDir: null },
157
+ };
158
+ }
159
+ const target = vault.vaultTarget(vaultScope());
114
160
  return {
115
161
  ok: true,
116
162
  configured: Boolean(key.secret),
@@ -125,7 +171,12 @@ function createFingerprintService({ dir, env } = {}) {
125
171
  // Where a publish goes, so the panel can say it before the user asks
126
172
  // for one — and so a misconfigured root is visible, not discovered by
127
173
  // a record landing in the wrong folder.
128
- vault: { remote: target.remote, root: target.root, recordsDir: `${target.root}/${vault.RECORDS_DIR}` },
174
+ vault: {
175
+ backend: target.backend,
176
+ remote: target.remote,
177
+ root: target.root,
178
+ recordsDir: target.backend === vault.BACKEND_CLOUD ? vault.CLOUD_PREFIX : `${target.root}/${vault.RECORDS_DIR}`,
179
+ },
129
180
  };
130
181
  } catch (e) {
131
182
  return { ok: false, error: (e && e.message) || String(e) };
@@ -155,7 +206,7 @@ function createFingerprintService({ dir, env } = {}) {
155
206
  * session carry the same behaviour digest — and the same AI digest, from the
156
207
  * same turns.
157
208
  */
158
- mint({ history = null, session = null, turns = null, label = null, account = null,
209
+ async mint({ history = null, session = null, turns = null, label = null, account = null,
159
210
  model = null, tools = null, codeRoots = null, codeDirty = null, out = null,
160
211
  createdAt = null, publish = false, aiSecret = undefined, aiModel = null } = {}) {
161
212
  try {
@@ -196,11 +247,15 @@ function createFingerprintService({ dir, env } = {}) {
196
247
  let vault = null;
197
248
  let vaultError = null;
198
249
  if (publish) {
199
- try {
200
- const key = authorship.resolveKey({ argv: [], env: scope });
201
- vault = load().vault.publish({ file, argv: [], env: scope, secret: key.secret });
202
- } catch (e) {
203
- vaultError = (e && e.message) || String(e);
250
+ if (vaultDisabled()) {
251
+ vaultError = VAULT_OFF_ERROR;
252
+ } else {
253
+ try {
254
+ const key = authorship.resolveKey({ argv: [], env: scope });
255
+ vault = await load().vault.publish({ file, argv: [], env: vaultScope(), secret: key.secret });
256
+ } catch (e) {
257
+ vaultError = (e && e.message) || String(e);
258
+ }
204
259
  }
205
260
  }
206
261
  return {
@@ -225,13 +280,14 @@ function createFingerprintService({ dir, env } = {}) {
225
280
  * preferred path is the file, because the sha256 in the anchor is then about
226
281
  * the bytes on disk rather than a re-serialisation of them.
227
282
  */
228
- publish({ file = null, record = null, dryRun = false } = {}) {
283
+ async publish({ file = null, record = null, dryRun = false } = {}) {
284
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
229
285
  try {
230
286
  const { authorship, vault } = load();
231
287
  const key = authorship.resolveKey({ argv: [], env: scope });
232
288
  return {
233
289
  ok: true,
234
- ...vault.publish({ file, record, argv: [], env: scope, secret: key.secret, dryRun }),
290
+ ...(await vault.publish({ file, record, argv: [], env: vaultScope(), secret: key.secret, dryRun })),
235
291
  };
236
292
  } catch (e) {
237
293
  return { ok: false, error: (e && e.message) || String(e) };
@@ -246,22 +302,24 @@ function createFingerprintService({ dir, env } = {}) {
246
302
  * sealed it has moved on" are three different situations and a single
247
303
  * boolean would flatten them into one that hides the interesting case.
248
304
  */
249
- anchor({ file = null, record = null, anchor = null } = {}) {
305
+ async anchor({ file = null, record = null, anchor = null } = {}) {
306
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
250
307
  try {
251
308
  const { authorship, vault } = load();
252
309
  if (!file && !record && !anchor) return { ok: false, error: 'anchor needs a record or an anchor' };
253
310
  const key = authorship.resolveKey({ argv: [], env: scope });
254
- return { ok: true, ...vault.checkAnchor({ file, record, anchor, argv: [], env: scope, key }) };
311
+ return { ok: true, ...(await vault.checkAnchor({ file, record, anchor, argv: [], env: vaultScope(), key })) };
255
312
  } catch (e) {
256
313
  return { ok: false, error: (e && e.message) || String(e) };
257
314
  }
258
315
  },
259
316
 
260
317
  /** What is in the vault. */
261
- vaultList() {
318
+ async vaultList() {
319
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
262
320
  try {
263
321
  const { vault } = load();
264
- return { ok: true, ...vault.list({ argv: [], env: scope }) };
322
+ return { ok: true, ...(await vault.list({ argv: [], env: vaultScope() })) };
265
323
  } catch (e) {
266
324
  return { ok: false, error: (e && e.message) || String(e) };
267
325
  }
@@ -272,11 +330,12 @@ function createFingerprintService({ dir, env } = {}) {
272
330
  * already there before publishing was restricted. A finding is a fact about
273
331
  * the vault, not about this app, so it is returned rather than acted on.
274
332
  */
275
- vaultAudit() {
333
+ async vaultAudit() {
334
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
276
335
  try {
277
336
  const { authorship, vault } = load();
278
337
  const key = authorship.resolveKey({ argv: [], env: scope });
279
- return { ok: true, ...vault.audit({ argv: [], env: scope, secret: key.secret || null }) };
338
+ return { ok: true, ...(await vault.audit({ argv: [], env: vaultScope(), secret: key.secret || null })) };
280
339
  } catch (e) {
281
340
  return { ok: false, error: (e && e.message) || String(e) };
282
341
  }
@@ -0,0 +1,171 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
+ // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
+ // Conveying a modified copy, or running one as a network service, owes the
5
+ // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
+ 'use strict';
7
+
8
+ /**
9
+ * google-connect.js — the desktop's Google identity + Drive flow.
10
+ *
11
+ * Two distinct steps, run in this order because the second depends on the
12
+ * first having actually landed:
13
+ *
14
+ * 1. Sign in with Google (`client/google-signin.js`'s local round-trip) —
15
+ * replaces this machine's saved AEGIS key with the one tied to that
16
+ * Google identity, exactly as `aegiscode connect` already does for the
17
+ * CLI (cli/src/connect.js). Desktop never had this wiring before this
18
+ * file; the only Google sign-in that existed anywhere in this product
19
+ * was the CLI's.
20
+ * 2. Connect Drive (`client/drive-connect.js`, which opens `/connect/drive`
21
+ * in the system browser and polls `/api/drive/token`) — grants the
22
+ * `drive.file` scope to WHICHEVER Google identity that browser's
23
+ * consent screen uses. That is only the SAME account as step 1 if step
24
+ * 1 ran first on this machine: `/connect/drive`'s callback resolves an
25
+ * account purely by Google identity, with no notion of "the account
26
+ * this machine's API key already belongs to" — so connecting Drive
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.
32
+ *
33
+ * Electron-free and fully injected — same shape as `account-key.js` next to
34
+ * it — so the whole flow runs under a test with a stub `aegis` client, a
35
+ * stub `googleSignIn`/`driveConnect`/`driveUpload`, and no browser, window or
36
+ * network.
37
+ */
38
+
39
+ function createGoogleConnect({
40
+ aegis,
41
+ credentials,
42
+ persistKey,
43
+ openUrl,
44
+ googleSignIn,
45
+ driveConnect,
46
+ driveUpload,
47
+ onAccountChange,
48
+ }) {
49
+ let signInInFlight = null;
50
+ let connectInFlight = null;
51
+ let lastSyncAt = null;
52
+ let lastSyncError = null;
53
+
54
+ /** Sign this machine in with Google, replacing whatever key it had. */
55
+ function signInWithGoogle() {
56
+ if (!signInInFlight) {
57
+ signInInFlight = (async () => {
58
+ try {
59
+ // Everything from here on (binding the local callback server,
60
+ // opening the browser, waiting for the round-trip) can fail, and
61
+ // every caller of this module — the IPC handler in main.js chief
62
+ // among them — is promised `{ ok, … }`/`{ ok: false, error }`,
63
+ // never a rejection. One try covers the whole flow instead of only
64
+ // the `session.wait()` half of it.
65
+ let session;
66
+ try {
67
+ session = await googleSignIn.startGoogleSignIn({ apiBase: aegis.apiBase });
68
+ } catch (e) {
69
+ return { ok: false, error: `could not start the local sign-in server — ${(e && e.message) || e}` };
70
+ }
71
+ const opened = Boolean(openUrl(session.url));
72
+ let result;
73
+ try {
74
+ result = await session.wait();
75
+ } catch (e) {
76
+ return { ok: false, opened, url: session.url, error: (e && e.message) || String(e) };
77
+ }
78
+ // Live client first: the very next call this process makes (e.g.
79
+ // the renderer asking for drive status right after this resolves)
80
+ // must see the new identity, not whatever key this process started
81
+ // with.
82
+ aegis.setApiKey(result.apiKey);
83
+ persistKey(result.apiKey);
84
+ if (result.memoryToken && credentials && typeof credentials.saveMemoryToken === 'function') {
85
+ try {
86
+ credentials.saveMemoryToken(result.memoryToken);
87
+ } catch {
88
+ /* best-effort — the account key itself is already saved */
89
+ }
90
+ }
91
+ if (typeof onAccountChange === 'function') onAccountChange();
92
+ return { ok: true, opened, url: session.url, username: result.username || null };
93
+ } finally {
94
+ signInInFlight = null;
95
+ }
96
+ })();
97
+ }
98
+ return signInInFlight;
99
+ }
100
+
101
+ /** Open `/connect/drive` and wait for this account to show connected. */
102
+ function connectDrive() {
103
+ if (!aegis.apiKey) {
104
+ return Promise.resolve({
105
+ ok: false,
106
+ error: 'sign in with Google first — this device has no AEGIS account key yet',
107
+ });
108
+ }
109
+ if (!connectInFlight) {
110
+ connectInFlight = (async () => {
111
+ try {
112
+ const url = driveConnect.driveConnectUrl(aegis.apiBase);
113
+ const opened = Boolean(openUrl(url));
114
+ try {
115
+ const status = await driveConnect.waitForDriveConnected(aegis);
116
+ return { ok: true, opened, url, connected: status.connected, email: status.email };
117
+ } catch (e) {
118
+ return { ok: false, opened, url, error: (e && e.message) || String(e) };
119
+ }
120
+ } finally {
121
+ connectInFlight = null;
122
+ }
123
+ })();
124
+ }
125
+ return connectInFlight;
126
+ }
127
+
128
+ /** A one-shot read: signed in, and if so, is Drive connected. */
129
+ async function status() {
130
+ if (!aegis.apiKey) return { signedIn: false, connected: false, email: null };
131
+ const s = await driveConnect.driveStatus(aegis);
132
+ return { signedIn: true, ...s };
133
+ }
134
+
135
+ /** Pull this account's AEGIS cloud memory and back it up to Drive. */
136
+ async function syncMemoryNow() {
137
+ try {
138
+ const tokenInfo = await aegis.driveToken();
139
+ if (!tokenInfo || !tokenInfo.connected || !tokenInfo.access_token) {
140
+ throw new Error('Google Drive is not connected yet');
141
+ }
142
+ const pulled = await aegis.memoryPull(0);
143
+ const entries = (pulled && pulled.entries) || [];
144
+ const content = JSON.stringify({ exportedAt: new Date().toISOString(), entries }, null, 2);
145
+ const result = await driveUpload.uploadMemoryBackup(tokenInfo.access_token, content);
146
+ lastSyncAt = Date.now();
147
+ lastSyncError = null;
148
+ return {
149
+ ok: true,
150
+ count: entries.length,
151
+ fileId: result.fileId,
152
+ webViewLink: result.webViewLink,
153
+ updated: result.updated,
154
+ lastSyncAt,
155
+ };
156
+ } catch (e) {
157
+ lastSyncError = (e && e.message) || String(e);
158
+ return { ok: false, error: lastSyncError, lastSyncAt };
159
+ }
160
+ }
161
+
162
+ return {
163
+ signInWithGoogle,
164
+ connectDrive,
165
+ status,
166
+ syncMemoryNow,
167
+ state: () => ({ lastSyncAt, lastSyncError }),
168
+ };
169
+ }
170
+
171
+ module.exports = { createGoogleConnect };
@@ -116,6 +116,23 @@ function claudeOAuthToken(env) {
116
116
  */
117
117
  const SHADOWING_ENV_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']);
118
118
 
119
+ /**
120
+ * Whether `value` is shaped like an Anthropic console API key.
121
+ *
122
+ * Deliberately a SHAPE test, not a validity test. This process cannot know
123
+ * whether a key is live without spending it, so it does not try: it answers
124
+ * only "could this possibly be the console credential that is allowed to
125
+ * outrank a subscription". Anthropic console keys are `sk-ant-`-prefixed. The
126
+ * check is case-sensitive and anchored, because that is the literal the vendor
127
+ * uses — a case-insensitive match would accept `SK-ANT-` junk.
128
+ *
129
+ * @param {string} value
130
+ * @returns {boolean}
131
+ */
132
+ function isAnthropicConsoleKey(value) {
133
+ return typeof value === 'string' && /^sk-ant-[A-Za-z0-9_-]{8,}$/.test(value.trim());
134
+ }
135
+
119
136
  /**
120
137
  * The env for one `claude` child, with a shadowing value that cannot be a
121
138
  * credential removed.
@@ -151,28 +168,69 @@ const SHADOWING_ENV_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_T
151
168
  * value that provably cannot be a key is dropped, and the drop is REPORTED
152
169
  * rather than silent (see `envDroppedLine`).
153
170
  *
171
+ * ── the second rule: a length floor is not a shape (measured, 2026-10-21) ───
172
+ *
173
+ * The floor above answers "is this too short to be anything?" and nothing more.
174
+ * A 35-character value that is NOT an Anthropic console key clears it, still
175
+ * wins the CLI's precedence, and still takes the plan down — so the floor alone
176
+ * reproduced the original bug one paste later. Measured state that produced it:
177
+ *
178
+ * ~/.aegiscode/.env held ANTHROPIC_API_KEY=<35 chars, not sk-ant-*>
179
+ * ~/.claude/.credentials.json held a valid `claudeAiOauth` for plan `pro`
180
+ * `aegiscode -p "say hi" --json`
181
+ * → {"text":"Invalid API key · Fix external API key","model":"sonnet",
182
+ * "tokens":0,"balance_eur":1.54} exit 0, in 2.3 s
183
+ *
184
+ * and the child was spawned with the stale value present (`ANTHROPIC_LEN=35`,
185
+ * observed by a fake `claude` on PATH). Unsetting the variable in the SHELL did
186
+ * not help either: this host loads `~/.aegiscode/.env` into `process.env`
187
+ * itself, so the file is an input no caller can undo.
188
+ *
189
+ * So when a subscription IS present, only a value shaped like an Anthropic
190
+ * console key may shadow it. That is the narrowest rule that keeps the promise
191
+ * the old comment made — "a user who set one deliberately is asking for the
192
+ * pay-per-token path" — while refusing a value that provably is not one. With
193
+ * no subscription there is nothing to shadow and no opinion is taken: any value
194
+ * clearing the floor passes, exactly as before.
195
+ *
154
196
  * @param {object} [baseEnv] the caller's env bag
155
197
  * @param {string} [token] a `CLAUDE_CODE_OAUTH_TOKEN` to hand the child
156
- * @returns {{env: object, dropped: Array<{name: string, length: number}>}}
198
+ * @param {{hasSubscription?: boolean}} [opts] whether a subscription is present
199
+ * (see the second rule above); absent means "assume not", which keeps every
200
+ * pre-existing caller and test on the old behaviour.
201
+ * @returns {{env: object, dropped: Array<{name: string, length: number, reason: string}>}}
157
202
  */
158
- function childEnvPlan(baseEnv, token) {
203
+ function childEnvPlan(baseEnv, token, opts) {
159
204
  const env = { ...(baseEnv || {}) };
160
205
  const dropped = [];
161
206
  const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
162
207
  ? envFileLib.credentialLooksUsable
163
208
  : null;
209
+ // Only a present subscription can be shadowed, so it is the only case that
210
+ // earns the stricter shape rule (see the docstring's second section).
211
+ const hasSubscription = !!(opts && opts.hasSubscription);
164
212
  // A host whose env module predates the floor keeps today's behaviour (the
165
213
  // variable passes through): a second copy of the floor here is the drift
166
214
  // client/env-file.js exists to prevent.
167
215
  if (usable) {
168
216
  for (const name of SHADOWING_ENV_VARS) {
169
217
  const value = env[name];
170
- if (typeof value !== 'string' || usable(value)) continue;
218
+ if (typeof value !== 'string') continue;
219
+ // Two independent reasons to refuse, and they are reported apart because
220
+ // they mean different things to the user: too short is junk, while a
221
+ // well-formed value that is not a console key is a key for something
222
+ // else that would silently outrank the plan they are paying for.
223
+ let reason = null;
224
+ if (!usable(value)) reason = 'too-short';
225
+ else if (hasSubscription && name === 'ANTHROPIC_API_KEY' && !isAnthropicConsoleKey(value)) {
226
+ reason = 'not-a-console-key';
227
+ }
228
+ if (!reason) continue;
171
229
  delete env[name];
172
230
  // A variable set to '' shadows nothing worth reporting — it is removed
173
231
  // (harmless) but the user is not told about a value they never gave.
174
232
  const length = value.trim().length;
175
- if (length > 0) dropped.push({ name, length });
233
+ if (length > 0) dropped.push({ name, length, reason });
176
234
  }
177
235
  }
178
236
  if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
@@ -183,20 +241,51 @@ function childEnvPlan(baseEnv, token) {
183
241
  function envDroppedLine(dropped) {
184
242
  const list = Array.isArray(dropped) ? dropped : [];
185
243
  if (!list.length) return null;
186
- const names = list.map((d) => `${d.name} (${d.length} character${d.length === 1 ? '' : 's'})`).join(', ');
187
- return `aegis: not passed to the claude child — ${names}: too short to be a credential, and the CLI would have spent it instead of your subscription`;
244
+ const shaped = list.filter((d) => d && d.reason === 'not-a-console-key');
245
+ const short = list.filter((d) => !d || d.reason !== 'not-a-console-key');
246
+ const parts = [];
247
+ if (short.length) {
248
+ const names = short.map((d) => `${d.name} (${d.length} character${d.length === 1 ? '' : 's'})`).join(', ');
249
+ parts.push(`${names}: too short to be a credential, and the CLI would have spent it instead of your subscription`);
250
+ }
251
+ if (shaped.length) {
252
+ const names = shaped.map((d) => `${d.name} (${d.length} characters)`).join(', ');
253
+ parts.push(
254
+ `${names}: not an Anthropic console key (\`sk-ant-\`), and the CLI would have spent it instead of your subscription`
255
+ );
256
+ }
257
+ return `aegis: not passed to the claude child — ${parts.join('; ')}`;
188
258
  }
189
259
 
190
- /** The shadowing value in `env` that cannot be a credential, if any (see above). */
191
- function shadowingValue(env) {
260
+ /**
261
+ * The shadowing value in `env` that cannot be spent as the credential the CLI
262
+ * would take it for, if any (see the two rules in `childEnvPlan`).
263
+ *
264
+ * This must agree with `childEnvPlan` exactly: a status surface that says "no
265
+ * shadowing value" while the child is in fact being handed one is worse than no
266
+ * report at all. Both reasons are therefore reported, and each carries the same
267
+ * `reason` tag the drop report uses so the two lines cannot drift apart.
268
+ *
269
+ * @param {object} env
270
+ * @param {{hasSubscription?: boolean}} [opts]
271
+ * @returns {{name: string, length: number, reason: string}|null}
272
+ */
273
+ function shadowingValue(env, opts) {
192
274
  const bag = env || {};
193
275
  const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
194
276
  ? envFileLib.credentialLooksUsable
195
277
  : null;
196
278
  if (!usable) return null;
279
+ const hasSubscription = !!(opts && opts.hasSubscription);
197
280
  for (const name of SHADOWING_ENV_VARS) {
198
281
  const value = bag[name];
199
- if (typeof value === 'string' && !usable(value)) return { name, length: value.trim().length };
282
+ if (typeof value !== 'string') continue;
283
+ let reason = null;
284
+ if (!usable(value)) reason = 'too-short';
285
+ else if (hasSubscription && name === 'ANTHROPIC_API_KEY' && !isAnthropicConsoleKey(value)) {
286
+ reason = 'not-a-console-key';
287
+ }
288
+ if (reason) return { name, length: value.trim().length, reason };
200
289
  }
201
290
  return null;
202
291
  }
@@ -264,7 +353,30 @@ function claudeProStatus(opts) {
264
353
  // child is fixed regardless (childEnvPlan); this reports it so a status
265
354
  // surface can say why, rather than leaving `apiKeySource: ANTHROPIC_API_KEY`
266
355
  // to be discovered from `claude auth status` by hand.
267
- out.shadowed = shadowingValue(env);
356
+ // A SUBSCRIPTION — the thing a stray `ANTHROPIC_API_KEY` would outrank — is an
357
+ // `oauth` plan or a `CLAUDE_CODE_OAUTH_TOKEN` handed to the child. A
358
+ // `credentials.json` holding a plain API key is not: there is no plan there to
359
+ // shadow, so the strict shape rule must not fire and drop the user's own key.
360
+ // This is computed once and published, so the report and the child env read
361
+ // ONE answer instead of each deciding for itself.
362
+ out.subscriptionPresent = out.loggedIn && out.kind !== 'apikey';
363
+ // The stricter rule only applies when a subscription is present, so the
364
+ // subscription this function just resolved is the same input the child env
365
+ // gets. Passing it here is what keeps `shadowed` and the drop report agreeing.
366
+ out.shadowed = shadowingValue(env, { hasSubscription: out.subscriptionPresent });
367
+ // "Can this host serve a `pro` turn at all?" is NOT the same question as
368
+ // "is there a subscription to shadow?" (above). A pin is phantom only when
369
+ // nothing usable survives to the child, and a bare API key in
370
+ // `credentials.json` (`kind: 'apikey'`) is a route with no plan behind it —
371
+ // so `subscriptionPresent` excludes it deliberately, while callability must
372
+ // count it. Answered by running the SAME planner the child env comes from, so
373
+ // this cannot disagree with what `claude` is actually handed: a variable the
374
+ // planner drops is not a credential, whatever it looks like in `env`.
375
+ const plan = childEnvPlan(env, null, { hasSubscription: out.subscriptionPresent });
376
+ out.credentialPresent = out.loggedIn
377
+ || typeof plan.env.ANTHROPIC_API_KEY === 'string'
378
+ || typeof plan.env.ANTHROPIC_AUTH_TOKEN === 'string'
379
+ || typeof plan.env.CLAUDE_CODE_OAUTH_TOKEN === 'string';
268
380
  return out;
269
381
  }
270
382
 
@@ -633,7 +745,18 @@ function runClaudePro(opts, deps) {
633
745
  // "API key: he" and the subscription is unreachable — see childEnvPlan.
634
746
  const baseEnv = o.env || process.env;
635
747
  const token = claudeOAuthToken(baseEnv);
636
- const plan = childEnvPlan(baseEnv, token);
748
+ // Whether a subscription is present decides whether a non-console
749
+ // `ANTHROPIC_API_KEY` may shadow it (childEnvPlan's second rule). It is read
750
+ // from the same credential file `claudeProStatus` reports on, and a failure
751
+ // to read it must NOT weaken the child's env: an unreadable file means "no
752
+ // subscription proven", which is the old behaviour, not a crash mid-turn.
753
+ let hasSubscription = false;
754
+ try {
755
+ hasSubscription = !!claudeProStatus({ env: baseEnv }).subscriptionPresent;
756
+ } catch {
757
+ /* unprovable subscription → the floor-only rule, exactly as before */
758
+ }
759
+ const plan = childEnvPlan(baseEnv, token, { hasSubscription });
637
760
  const childEnv = plan.env;
638
761
  const droppedLine = envDroppedLine(plan.dropped);
639
762
  if (droppedLine && o.onDelta) {