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 +11 -7
- package/lib/fingerprint.js +76 -17
- package/lib/google-connect.js +171 -0
- package/lib/local/claude-pro.js +134 -11
- package/lib/local/engine.js +111 -26
- package/lib/local/prompt.js +67 -37
- package/lib/local/tools.js +49 -13
- package/lib/settings.js +65 -24
- package/main.js +270 -36
- package/package.json +5 -2
- package/preload.js +45 -12
- package/renderer/app.js +691 -22
- package/renderer/budget.js +18 -9
- package/renderer/companion.js +225 -0
- package/renderer/fingerprint.css +16 -0
- package/renderer/fingerprint.js +136 -2
- package/renderer/index.html +302 -121
- package/renderer/provider-toolbar.js +136 -0
- package/renderer/providers.js +261 -0
- package/renderer/style.css +547 -171
- package/vendor/aegis.js +169 -3
- package/vendor/credentials.js +9 -2
- package/vendor/drive-connect.js +87 -0
- package/vendor/drive-upload.js +176 -0
- package/vendor/google-signin.js +127 -0
- package/vendor/record-vault.js +431 -113
- package/vendor/update.js +366 -8
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."
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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:
|
package/lib/fingerprint.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 };
|
package/lib/local/claude-pro.js
CHANGED
|
@@ -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
|
-
* @
|
|
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'
|
|
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
|
|
187
|
-
|
|
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
|
-
/**
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|