aegis-desktop 1.0.9 → 1.0.12
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 +1 -1
- package/lib/auto.js +11 -7
- package/lib/fingerprint.js +76 -17
- package/lib/local/autonomous.js +48 -8
- package/lib/local/claude-pro.js +134 -11
- package/lib/local/engine.js +47 -25
- package/lib/settings.js +65 -24
- package/main.js +79 -24
- package/package.json +10 -5
- package/preload.js +31 -12
- package/renderer/app.js +676 -17
- package/renderer/budget.js +18 -9
- package/renderer/companion.js +225 -0
- package/renderer/fingerprint.css +16 -0
- package/renderer/fingerprint.js +62 -2
- package/renderer/index.html +285 -121
- package/renderer/provider-toolbar.js +136 -0
- package/renderer/providers.js +261 -0
- package/renderer/style.css +547 -171
- package/vendor/credentials.js +9 -2
- package/vendor/google-signin.js +127 -0
- package/vendor/record-vault.js +431 -113
- package/vendor/update.js +266 -0
package/README.md
CHANGED
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
|
}
|
package/lib/local/autonomous.js
CHANGED
|
@@ -209,8 +209,15 @@ const DEFAULT_MODEL = 'nexus-brain';
|
|
|
209
209
|
* depends on what the user's own daemon happens to have. Both REQUIRE an
|
|
210
210
|
* explicit model, refused by name instead of silently resolving to
|
|
211
211
|
* something nobody chose.
|
|
212
|
+
* - 'pro' DOES have a default, but not one this app chooses: a blank model
|
|
213
|
+
* means "the `claude` CLI picks for the subscription", which is what
|
|
214
|
+
* `--model` omitted says (engine.js sends no flag for `default`). Once a
|
|
215
|
+
* model IS stated it must be one of claude-pro.js's PRO_MODELS aliases —
|
|
216
|
+
* the same list the model picker offers — because the child accepts only
|
|
217
|
+
* those four spellings, and a `provider:model` id copied from a byok task
|
|
218
|
+
* would otherwise be handed to `claude --model` and fail there.
|
|
212
219
|
*/
|
|
213
|
-
const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
|
|
220
|
+
const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local', 'pro']);
|
|
214
221
|
|
|
215
222
|
const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
|
|
216
223
|
const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
|
|
@@ -270,6 +277,34 @@ function modelRefusal(id, cls) {
|
|
|
270
277
|
}
|
|
271
278
|
return '';
|
|
272
279
|
}
|
|
280
|
+
if (normalized === 'pro') {
|
|
281
|
+
// The subscription route. A blank model is legal and meaningful: engine.js
|
|
282
|
+
// sends no `--model` at all for 'default', so the child resolves the pick
|
|
283
|
+
// itself. Anything STATED must be a PRO_MODELS alias, because those four
|
|
284
|
+
// spellings are exactly what `claude --model` accepts — a byok-style
|
|
285
|
+
// "provider:model" copied between tasks would be handed to the child and
|
|
286
|
+
// fail there, three stages into a drain, with the queue's own file naming
|
|
287
|
+
// something the queue never validated.
|
|
288
|
+
if (!stated) return '';
|
|
289
|
+
// Required lazily: claude-pro.js is a heavy sibling (child spawn, session
|
|
290
|
+
// files) and this module is loaded by queue.js at every CLI start. The
|
|
291
|
+
// require is a plain sibling path with no back-require, so there is no
|
|
292
|
+
// cycle to guard — this is about load cost, not correctness.
|
|
293
|
+
let aliases;
|
|
294
|
+
try {
|
|
295
|
+
aliases = require('./claude-pro.js').PRO_MODELS;
|
|
296
|
+
} catch {
|
|
297
|
+
// If the alias list cannot be read we cannot validate a stated model, and
|
|
298
|
+
// accepting it unvalidated is the exact failure this arm exists to stop.
|
|
299
|
+
return `a pro task's model cannot be checked right now — queue it with no --model to let the claude CLI pick`;
|
|
300
|
+
}
|
|
301
|
+
const ids = (Array.isArray(aliases) ? aliases : []).map((m) => String(m && m.id).trim());
|
|
302
|
+
if (ids.includes(stated.toLowerCase())) return '';
|
|
303
|
+
return (
|
|
304
|
+
`the autonomous queue's Pro class runs the subscription through the claude CLI, ` +
|
|
305
|
+
`which accepts only its own model aliases (${ids.join('/')}); "${stated}" is not one`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
273
308
|
// normalized === 'local'
|
|
274
309
|
if (!stated) return 'a local task needs a model — the tag of whatever is pulled into the local daemon';
|
|
275
310
|
return '';
|
|
@@ -594,13 +629,18 @@ function createQueueWorker({
|
|
|
594
629
|
*/
|
|
595
630
|
async function runTask(item, { carry = '', commit } = {}) {
|
|
596
631
|
const cwd = item.cwd || process.cwd();
|
|
597
|
-
//
|
|
598
|
-
// item whose model
|
|
599
|
-
// `--model` the CLI accepted before this rule existed,
|
|
600
|
-
// older build) would otherwise go out
|
|
601
|
-
//
|
|
602
|
-
// spelling, an opaque server error otherwise
|
|
603
|
-
//
|
|
632
|
+
// The task's own (model, class) pair checked BEFORE the turn, not at the
|
|
633
|
+
// server. An item whose model does not belong to its class (a hand-edited
|
|
634
|
+
// queue file, a `--model` the CLI accepted before this rule existed,
|
|
635
|
+
// another host's older build) would otherwise go out under a transport
|
|
636
|
+
// that does not serve it: billed work if it happens to be a per-provider
|
|
637
|
+
// spelling, an opaque server error otherwise, and for 'pro' a `claude
|
|
638
|
+
// --model` that the child rejects. Failing here names the model and the
|
|
639
|
+
// spellings that class accepts, and costs nothing.
|
|
640
|
+
//
|
|
641
|
+
// "Aegis Cloud or nothing" was this comment's old claim, and it stopped
|
|
642
|
+
// being true when byok/local were added (9eeef09) — modelRefusal is what
|
|
643
|
+
// actually enforces the rule now, per class, and it runs for all four.
|
|
604
644
|
const cls = normalizeClass(item.class);
|
|
605
645
|
const refusal = modelRefusal(item.model, cls);
|
|
606
646
|
if (refusal) {
|
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) {
|
package/lib/local/engine.js
CHANGED
|
@@ -524,27 +524,43 @@ const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
|
|
|
524
524
|
/**
|
|
525
525
|
* DeepSeek native reasoning level per config rung.
|
|
526
526
|
*
|
|
527
|
-
* The field is `
|
|
528
|
-
*
|
|
529
|
-
* `
|
|
530
|
-
*
|
|
531
|
-
* `reasoning_effort:"none"` produced 0 reasoning_content chars and `"max"`
|
|
532
|
-
* produced 138, whereas `effort:"none"` still produced 112 — thinking stayed
|
|
533
|
-
* on. So the old field was inert on every turn, and inertness is the worst
|
|
534
|
-
* failure here: it 200s, nothing surfaces, and the model merely thinks at the
|
|
535
|
-
* wrong depth.
|
|
527
|
+
* The field is `effort`, NOT `reasoning_effort`. Verified live 2026-10-21
|
|
528
|
+
* against the surface this client actually reaches — the relay's
|
|
529
|
+
* `https://api.deepseek.com/anthropic` transport — by measuring output tokens
|
|
530
|
+
* over interleaved paired samples (n=12) against an absent-field baseline:
|
|
536
531
|
*
|
|
537
|
-
*
|
|
538
|
-
* than the earlier (low|max, no medium) fold, which rested on the mistaken
|
|
539
|
-
* belief that DeepSeek had no `medium`. `max_tokens` (EFFORT_TOKEN_BUDGET)
|
|
540
|
-
* still scales as the output ceiling, since hidden chain-of-thought shares that
|
|
541
|
-
* budget on DeepSeek.
|
|
532
|
+
* absent 818 · low 772 · medium 836 · high 818 · max 1494
|
|
542
533
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
534
|
+
* Only `max` separates from sending nothing. `GET /models` reports
|
|
535
|
+
* `supported_levels: ["low","high","max"]`, default `high`, on both live ids
|
|
536
|
+
* (`deepseek-flash`, `deepseek-v4-pro`) — so `medium` is not a level these
|
|
537
|
+
* models have. It passes the deserializer (unknown values *do* 422 with the
|
|
538
|
+
* enum, so validation is real) and is then absorbed into the default, which is
|
|
539
|
+
* this codebase's worst failure mode: a 200, nothing surfaced, and the model
|
|
540
|
+
* thinking at a depth nobody chose.
|
|
541
|
+
*
|
|
542
|
+
* What the earlier version of this comment asserted — that `reasoning_effort`
|
|
543
|
+
* binds and `effort` is ignored — was measured on the vendor's OpenAI-shaped
|
|
544
|
+
* `/chat/completions`, a surface this client never reaches, and it led to the
|
|
545
|
+
* knob being dead end-to-end: aegis1 forwards `reasoning_effort` only for
|
|
546
|
+
* BYOK_REASONING_EFFORT_PROVIDERS (openai/groq/gemini/grok — deepseek is
|
|
547
|
+
* absent), so the field was dropped at the relay. The `effort` channel is the
|
|
548
|
+
* one aegis1 gates on `provider_id == "deepseek"` (app.py) and that the
|
|
549
|
+
* Anthropic adapter nests as `output_config.effort`; it is the only channel
|
|
550
|
+
* that reaches the vendor, and the only one that measurably binds.
|
|
551
|
+
*
|
|
552
|
+
* The map folds `medium` upward for the same reason the Gemini row does: the
|
|
553
|
+
* vendor has no such level, and a rung that 200s while doing nothing is worse
|
|
554
|
+
* than one that says what it does. `max` is deliberately NOT reachable — it is
|
|
555
|
+
* not a rung of this knob, and mapping the default rung onto it would silently
|
|
556
|
+
* multiply reasoning spend on every turn nobody asked to deepen.
|
|
557
|
+
* `max_tokens` (EFFORT_TOKEN_BUDGET) still scales as the output ceiling, since
|
|
558
|
+
* hidden chain-of-thought shares that budget on DeepSeek.
|
|
559
|
+
*
|
|
560
|
+
* desktop/renderer/budget.js carries the renderer's copy; test/budget.test.mjs
|
|
561
|
+
* compares the two tables, so the mirror cannot drift silently.
|
|
546
562
|
*/
|
|
547
|
-
const DEEPSEEK_EFFORT = { low: 'low', medium: '
|
|
563
|
+
const DEEPSEEK_EFFORT = { low: 'low', medium: 'high', high: 'high' };
|
|
548
564
|
|
|
549
565
|
/**
|
|
550
566
|
* Per-family effort translation, mirroring the upstream engine's
|
|
@@ -565,7 +581,7 @@ const DEEPSEEK_EFFORT = { low: 'low', medium: 'medium', high: 'high' };
|
|
|
565
581
|
const EFFORT_IDENTITY = { low: 'low', medium: 'medium', high: 'high' };
|
|
566
582
|
const EFFORT_INERT_RE = /non-reasoning/i;
|
|
567
583
|
const EFFORT_DIALECTS = [
|
|
568
|
-
{ provider: 'deepseek', re: DEEPSEEK_REASONING_MODEL_RE, field: '
|
|
584
|
+
{ provider: 'deepseek', re: DEEPSEEK_REASONING_MODEL_RE, field: 'effort', map: DEEPSEEK_EFFORT },
|
|
569
585
|
{ provider: 'openai', re: /^(gpt-5|o[0-9])/i, field: 'reasoning_effort', map: EFFORT_IDENTITY },
|
|
570
586
|
{ provider: 'groq', re: /^(openai\/gpt-oss|qwen\/qwen3)/i, field: 'reasoning_effort', map: EFFORT_IDENTITY },
|
|
571
587
|
{ provider: 'xai', re: /^grok-(4\.[5-9]|[5-9])/, field: 'reasoning_effort', map: EFFORT_IDENTITY },
|
|
@@ -1967,8 +1983,8 @@ function createLocalEngine({
|
|
|
1967
1983
|
const { provider, model: bareModel } = splitByokModel(opts.model);
|
|
1968
1984
|
// Translate the rung for THIS vendor once, and carry the verdict rather
|
|
1969
1985
|
// than a bare level: the relay validates strictly upstream and the field
|
|
1970
|
-
// name is not portable (DeepSeek's
|
|
1971
|
-
//
|
|
1986
|
+
// name is not portable (DeepSeek's `/anthropic` transport takes `effort`,
|
|
1987
|
+
// OpenAI-shaped vendors take `reasoning_effort`). Undefined for a family
|
|
1972
1988
|
// with no dialect, which the transport then omits entirely.
|
|
1973
1989
|
const effort = nativeEffort(provider, bareModel, opts.effort);
|
|
1974
1990
|
try {
|
|
@@ -1999,10 +2015,16 @@ function createLocalEngine({
|
|
|
1999
2015
|
toolChoice: opts.toolChoice,
|
|
2000
2016
|
// Native effort knob, field and level both from the dialect table.
|
|
2001
2017
|
// `bareModel` is already provider-less, so it matches the dialect
|
|
2002
|
-
// regexes without further stripping.
|
|
2003
|
-
//
|
|
2004
|
-
// `
|
|
2005
|
-
//
|
|
2018
|
+
// regexes without further stripping. Both wire channels are live, and
|
|
2019
|
+
// which one is used is the dialect's decision, not this call site's:
|
|
2020
|
+
// DeepSeek's `/anthropic` transport takes `effort` (aegis1 gates it on
|
|
2021
|
+
// provider_id == "deepseek" and the adapter nests it as
|
|
2022
|
+
// `output_config.effort`), while the OpenAI-shaped vendors take
|
|
2023
|
+
// `reasoning_effort`. Routing on the field name is what keeps a
|
|
2024
|
+
// dialect from being silently dropped here — the previous form
|
|
2025
|
+
// forwarded only `reasoning_effort`, so turning DeepSeek's dialect
|
|
2026
|
+
// over to `effort` would have died at this line.
|
|
2027
|
+
effort: effort && effort.field === 'effort' ? effort.level : undefined,
|
|
2006
2028
|
reasoningEffort: effort && effort.field === 'reasoning_effort' ? effort.level : undefined,
|
|
2007
2029
|
stream: true,
|
|
2008
2030
|
onStream: opts.onDelta,
|