aegis-desktop 0.8.29 → 0.8.31
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/local/autonomous.js +105 -38
- package/lib/local/engine.js +13 -0
- package/lib/local/queue.js +20 -16
- package/lib/settings.js +48 -14
- package/main.js +17 -9
- package/package.json +1 -1
- package/preload.js +7 -2
- package/renderer/app.js +145 -2
- package/renderer/index.html +18 -0
- package/vendor/prefs.js +32 -2
package/lib/local/autonomous.js
CHANGED
|
@@ -97,48 +97,100 @@ function writtenPath(tool) {
|
|
|
97
97
|
const DEFAULT_MODEL = 'nexus-brain';
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* THE QUEUE RUNS ON
|
|
100
|
+
* THE QUEUE RUNS ON WHATEVER CLASS THE TASK NAMES — aegis, byok, or local —
|
|
101
|
+
* the same three classes engine.js's interactive turns serve (engine.js's
|
|
102
|
+
* exported `CLASSES`). This module does not `require('./engine.js')` to read
|
|
103
|
+
* that list itself (same reason queue.js stays engine-free): duplicating the
|
|
104
|
+
* three names here keeps both modules testable with no Electron, no network,
|
|
105
|
+
* and no model.
|
|
101
106
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
107
|
+
* This used to be Aegis Cloud only, because runTask sent every turn out with
|
|
108
|
+
* a hardcoded `class: 'aegis'` — a byok or local model id would have gone out
|
|
109
|
+
* under the wrong transport and failed at the server, or worse, silently
|
|
110
|
+
* picked up the wrong provider's key. Now `class` travels with the task
|
|
111
|
+
* (`item.class`, defaulted to 'aegis' for every task that predates this field
|
|
112
|
+
* — the CLI, a hand-written queue file, the old desktop card), and runTask
|
|
113
|
+
* forwards it verbatim instead of overwriting it.
|
|
109
114
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
115
|
+
* Each class validates differently, because each has a different notion of
|
|
116
|
+
* "no model stated is fine":
|
|
117
|
+
* - 'aegis' has a real default (the pooled brain), so a blank model is
|
|
118
|
+
* accepted and resolveModel fills it in. The accept-list is the
|
|
119
|
+
* pooled-brain tier family — the one entry engine.js's offerableCatalog
|
|
120
|
+
* offers for the Aegis Cloud class, plus the `-smart`/`-neo` tier
|
|
121
|
+
* spellings the server still serves as aliases of it (mirrors
|
|
122
|
+
* selectBrainEntry() there rather than re-deriving "anything starting
|
|
123
|
+
* with nexus-": `nexus-fast` is not a tier the catalog has ever served).
|
|
124
|
+
* - 'byok' and 'local' have no such default: a blank byok model cannot be
|
|
125
|
+
* "whichever provider the pool picks" (there is no pool), and a blank
|
|
126
|
+
* local model cannot be "whichever model is pulled" when the answer
|
|
127
|
+
* depends on what the user's own daemon happens to have. Both REQUIRE an
|
|
128
|
+
* explicit model, refused by name instead of silently resolving to
|
|
129
|
+
* something nobody chose.
|
|
117
130
|
*/
|
|
131
|
+
const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
|
|
132
|
+
|
|
118
133
|
const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
|
|
119
134
|
const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
|
|
120
135
|
|
|
121
|
-
/** True when `id` names an AEGIS Cloud pooled-brain tier
|
|
136
|
+
/** True when `id` names an AEGIS Cloud pooled-brain tier. */
|
|
122
137
|
function isAegisModel(id) {
|
|
123
138
|
return AEGIS_MODEL_RE.test(String(id == null ? '' : id).trim());
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
142
|
+
* Normalize a task's stated class to one this module (and engine.js) knows.
|
|
143
|
+
* Blank or absent — every task queued before this field existed — defaults to
|
|
144
|
+
* 'aegis', the only class the queue ever ran before today.
|
|
145
|
+
*/
|
|
146
|
+
function normalizeClass(cls) {
|
|
147
|
+
const stated = String(cls == null ? '' : cls).trim().toLowerCase();
|
|
148
|
+
return stated || 'aegis';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether `id` is shaped "<provider>:<model>" — the only form engine.js's
|
|
153
|
+
* byok class accepts (splitByokModel). Not re-deriving the live provider list
|
|
154
|
+
* here (that is a catalog call, `listModels('byok')`, this module has no
|
|
155
|
+
* business caching) — just enough of a shape check that a malformed id is
|
|
156
|
+
* refused at the door instead of failing splitByokModel at the server with no
|
|
157
|
+
* context.
|
|
158
|
+
*/
|
|
159
|
+
function looksLikeByokModel(id) {
|
|
160
|
+
const s = String(id == null ? '' : id).trim();
|
|
161
|
+
const i = s.indexOf(':');
|
|
162
|
+
return i > 0 && i < s.length - 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Why a STATED (model, class) pair cannot be queued, or '' when it can.
|
|
129
167
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* otherwise: `class: 'aegis'` would be sent with an id the server does not
|
|
133
|
-
* serve, and the task would come back as an opaque error minutes later.
|
|
168
|
+
* `cls` defaults to 'aegis' when omitted, so every existing caller of
|
|
169
|
+
* `modelRefusal(id)` keeps its old behaviour verbatim.
|
|
134
170
|
*/
|
|
135
|
-
function modelRefusal(id) {
|
|
171
|
+
function modelRefusal(id, cls) {
|
|
136
172
|
const stated = String(id == null ? '' : id).trim();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`
|
|
140
|
-
|
|
141
|
-
)
|
|
173
|
+
const normalized = normalizeClass(cls);
|
|
174
|
+
if (!MODEL_CLASSES.includes(normalized)) {
|
|
175
|
+
return `"${cls}" is not a model class the queue serves (${MODEL_CLASSES.join('/')})`;
|
|
176
|
+
}
|
|
177
|
+
if (normalized === 'aegis') {
|
|
178
|
+
if (!stated || isAegisModel(stated)) return '';
|
|
179
|
+
return (
|
|
180
|
+
`the autonomous queue's Aegis Cloud class runs pooled-brain models only (${DEFAULT_MODEL}, ` +
|
|
181
|
+
`${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (normalized === 'byok') {
|
|
185
|
+
if (!stated) return 'a byok task needs a model, as "<provider>:<model>" (pick one from /models)';
|
|
186
|
+
if (!looksLikeByokModel(stated)) {
|
|
187
|
+
return `"${stated}" is not a byok model id — it must be "<provider>:<model>" (pick one from /models)`;
|
|
188
|
+
}
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
// normalized === 'local'
|
|
192
|
+
if (!stated) return 'a local task needs a model — the tag of whatever is pulled into the local daemon';
|
|
193
|
+
return '';
|
|
142
194
|
}
|
|
143
195
|
|
|
144
196
|
/** Phrase match for "work autonomously" in a prompt or a queued task. */
|
|
@@ -239,10 +291,14 @@ function withRoundHorizon(rounds, env, fn) {
|
|
|
239
291
|
* task over it would break drains for a reason that is not the task's
|
|
240
292
|
* fault; a fallback to the pooled brain keeps the drain honest and on-cloud.
|
|
241
293
|
*/
|
|
242
|
-
function resolveModel({ model, env } = {}) {
|
|
243
|
-
const e = env || process.env;
|
|
294
|
+
function resolveModel({ model, env, cls } = {}) {
|
|
244
295
|
const picked = String(model || '').trim();
|
|
296
|
+
// byok/local have no cloud default to fall back to — modelRefusal already
|
|
297
|
+
// guaranteed a non-empty, shaped model at queue time, so the task's own pick
|
|
298
|
+
// is the only answer for either class.
|
|
299
|
+
if (normalizeClass(cls) !== 'aegis') return picked;
|
|
245
300
|
if (picked) return picked;
|
|
301
|
+
const e = env || process.env;
|
|
246
302
|
const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
|
|
247
303
|
return isAegisModel(fromEnv) ? fromEnv : DEFAULT_MODEL;
|
|
248
304
|
}
|
|
@@ -255,7 +311,8 @@ function resolveModel({ model, env } = {}) {
|
|
|
255
311
|
* it: the queue falls back to the pool, and the one place that says so is this
|
|
256
312
|
* string, which runTask emits as a note and the desktop card shows.
|
|
257
313
|
*/
|
|
258
|
-
function ignoredEnvModel({ model, env } = {}) {
|
|
314
|
+
function ignoredEnvModel({ model, env, cls } = {}) {
|
|
315
|
+
if (normalizeClass(cls) !== 'aegis') return ''; // AEGIS_AUTONOMOUS_MODEL is an Aegis-only pin
|
|
259
316
|
if (String(model || '').trim()) return ''; // the task's own pick is what counts
|
|
260
317
|
const e = env || process.env;
|
|
261
318
|
const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
|
|
@@ -462,16 +519,23 @@ function createQueueWorker({
|
|
|
462
519
|
// pool does not serve: billed work if it happens to be a per-provider
|
|
463
520
|
// spelling, an opaque server error otherwise. Failing here names the model
|
|
464
521
|
// and the allowed ones, and costs nothing.
|
|
465
|
-
const
|
|
522
|
+
const cls = normalizeClass(item.class);
|
|
523
|
+
const refusal = modelRefusal(item.model, cls);
|
|
466
524
|
if (refusal) {
|
|
467
|
-
const failed = { ok: false, error: refusal, model: item.model, ms: 0 };
|
|
525
|
+
const failed = { ok: false, error: refusal, model: item.model, class: cls, ms: 0 };
|
|
468
526
|
emit({ type: 'finish', taskId: item.id, ok: false, result: failed });
|
|
469
527
|
return failed;
|
|
470
528
|
}
|
|
471
|
-
const model = resolveModel({ model: item.model, env });
|
|
472
|
-
const ignored = ignoredEnvModel({ model: item.model, env });
|
|
529
|
+
const model = resolveModel({ model: item.model, env, cls });
|
|
530
|
+
const ignored = ignoredEnvModel({ model: item.model, env, cls });
|
|
473
531
|
if (ignored) emit({ type: 'note', taskId: item.id, note: ignored });
|
|
474
|
-
|
|
532
|
+
// The pooled-brain fan-out is an Aegis Cloud mechanism (aegis1
|
|
533
|
+
// services/pool_brain.py) — engine.js itself only honours `autonomous`
|
|
534
|
+
// under `cls === 'aegis'` (engine.js:1524), so a byok/local task asking for
|
|
535
|
+
// it is forced to a plain single pass HERE rather than relying on that
|
|
536
|
+
// silent no-op, which would otherwise size the task's effort/round
|
|
537
|
+
// reporting as if a fan-out it never got had been granted.
|
|
538
|
+
const fanoutAsked = cls === 'aegis' && resolveFanout(item, env);
|
|
475
539
|
// The clamp. `canFanOut()` is the entitlement answer (true everywhere
|
|
476
540
|
// except a non-Pro desktop session), and it can only ever take a fan-out
|
|
477
541
|
// AWAY: a task the session is not entitled to fan out runs as one plain
|
|
@@ -526,14 +590,14 @@ function createQueueWorker({
|
|
|
526
590
|
const wantCommit = commit === undefined ? Boolean(item.commit) : Boolean(commit);
|
|
527
591
|
const before = wantCommit ? safe(() => git.gitStatusSnapshot(cwd), null) : null;
|
|
528
592
|
|
|
529
|
-
emit({ type: 'start', taskId: item.id, model, cwd, rounds, fanout, effort });
|
|
593
|
+
emit({ type: 'start', taskId: item.id, model, class: cls, cwd, rounds, fanout, effort });
|
|
530
594
|
const started = now();
|
|
531
595
|
let result;
|
|
532
596
|
try {
|
|
533
597
|
const res = await withRoundHorizon(rounds, env, () =>
|
|
534
598
|
engine.chat(
|
|
535
599
|
{
|
|
536
|
-
class:
|
|
600
|
+
class: cls,
|
|
537
601
|
model,
|
|
538
602
|
prompt: taskPrompt(item, { carry, rounds }),
|
|
539
603
|
// The pooled-brain fan-out ("fan out this turn" in the GUI chat
|
|
@@ -831,8 +895,11 @@ module.exports = {
|
|
|
831
895
|
DEFAULT_MODEL,
|
|
832
896
|
DEFAULT_ROUNDS,
|
|
833
897
|
MAX_ROUND_STOPS,
|
|
898
|
+
MODEL_CLASSES,
|
|
834
899
|
AEGIS_MODEL_IDS,
|
|
835
900
|
isAegisModel,
|
|
901
|
+
normalizeClass,
|
|
902
|
+
looksLikeByokModel,
|
|
836
903
|
modelRefusal,
|
|
837
904
|
WRITE_TOOLS,
|
|
838
905
|
writtenPath,
|
package/lib/local/engine.js
CHANGED
|
@@ -968,6 +968,19 @@ function createLocalEngine({
|
|
|
968
968
|
// reverse its verdict. An approval that could waive Λ2 would be the exact
|
|
969
969
|
// capture K3 describes. To change what is permitted, the covenant text
|
|
970
970
|
// itself has to be re-sealed — which no runtime input can do.
|
|
971
|
+
//
|
|
972
|
+
// DO NOT MOVE THIS BELOW A SHORT-CIRCUIT. It sits above every `return` of
|
|
973
|
+
// the confirmation gate on purpose (ON8/D8 rule 2): `gatedExecuteTool`
|
|
974
|
+
// short-circuits on `!confirmModeEnabled()`, on a remembered session
|
|
975
|
+
// "allow", and on a `/permissions` skip — and since ON8 the confirm-mode
|
|
976
|
+
// preference is the SHARED store's, i.e. the same value the terminal face
|
|
977
|
+
// reads, and `--dangerously-skip-permissions` can arrive in that store as
|
|
978
|
+
// well. A refactor that hoists this below any of those three would make
|
|
979
|
+
// the covenant waivable by a setting a user can write, which is the one
|
|
980
|
+
// thing D8 rule 2 forbids outright. The frozen classes
|
|
981
|
+
// (`write`/`shell`/`network`/`offDevice`, always `'ask'`; see
|
|
982
|
+
// lib/avatar/level.js `LEVEL_INDEPENDENT_APPROVALS`) have the same floor
|
|
983
|
+
// and are asserted beside this one by one/test/permissions.test.mjs.
|
|
971
984
|
const cv = covenant.preActionAssert({ tool: name, args: args || {} });
|
|
972
985
|
if (!cv.allowed) {
|
|
973
986
|
// Non-silent (never a quiet no-op) and bound for Λ4 accountability.
|
package/lib/local/queue.js
CHANGED
|
@@ -176,34 +176,38 @@ function upsert(items, entry) {
|
|
|
176
176
|
|
|
177
177
|
/**
|
|
178
178
|
* Queue a task. `cwd` is where the work happens (the worker runs the turn
|
|
179
|
-
* with it as the tool loop's working directory), `model`
|
|
180
|
-
*
|
|
181
|
-
*
|
|
179
|
+
* with it as the tool loop's working directory), `class` + `model` are the
|
|
180
|
+
* same pair engine.js's interactive turns use ('aegis'/'byok'/'local' and the
|
|
181
|
+
* model id that class expects), and `commit` asks the worker to commit what
|
|
182
|
+
* the task changed (scoped — see autonomous.js).
|
|
182
183
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
184
|
+
* REFUSED AT THE DOOR, not three stages into a drain. `class` defaults to
|
|
185
|
+
* 'aegis' (every task queued before this field existed), and the (model,
|
|
186
|
+
* class) pair has to pass autonomous.js's own accept-list — the CLI
|
|
187
|
+
* (`aegiscode autonomous add --model`), the desktop card and a hand-written
|
|
188
|
+
* queue file are all held to the same rule, so a bad row fails here with a
|
|
189
|
+
* reason instead of coming back as an opaque server error after being picked
|
|
190
|
+
* up. `model: null` stays legal for the 'aegis' class only: that is "no
|
|
191
|
+
* pick", and autonomous.resolveModel fills in the pooled brain. 'byok' and
|
|
192
|
+
* 'local' have no such default and are refused if blank — see
|
|
193
|
+
* autonomous.modelRefusal. Required lazily: queue.js is a dependency of
|
|
194
|
+
* autonomous.js, and this check must not turn that into a load-order cycle.
|
|
192
195
|
*/
|
|
193
196
|
function addTask(env, opts = {}) {
|
|
194
197
|
const task = String(opts.task == null ? '' : opts.task).trim();
|
|
195
198
|
if (!task) throw new Error('queue: a task needs text');
|
|
199
|
+
const autonomousMod = require('./autonomous.js');
|
|
200
|
+
const cls = autonomousMod.normalizeClass(opts.class);
|
|
196
201
|
const statedModel = String(opts.model == null ? '' : opts.model).trim();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (refusal) throw new Error(`queue: ${refusal}`);
|
|
200
|
-
}
|
|
202
|
+
const refusal = autonomousMod.modelRefusal(statedModel, cls);
|
|
203
|
+
if (refusal) throw new Error(`queue: ${refusal}`);
|
|
201
204
|
const items = loadQueue(env);
|
|
202
205
|
const now = opts.now || Date.now();
|
|
203
206
|
const entry = {
|
|
204
207
|
id: nextId(items),
|
|
205
208
|
task,
|
|
206
209
|
cwd: opts.cwd || process.cwd(),
|
|
210
|
+
class: cls,
|
|
207
211
|
model: statedModel || null,
|
|
208
212
|
effort: opts.effort || null,
|
|
209
213
|
workers: Number.isInteger(opts.workers) ? opts.workers : null,
|
package/lib/settings.js
CHANGED
|
@@ -270,6 +270,15 @@ function createSettingsStore({ dir, safeStorage, prefsDir, env } = {}) {
|
|
|
270
270
|
try {
|
|
271
271
|
return prefs.adopt({ dir: sharedDir, legacyDir: dir });
|
|
272
272
|
} catch (err) {
|
|
273
|
+
// Best-effort, but NOT silent (AEGISONE ON8). Swallowing this throw is how
|
|
274
|
+
// `prefs.adopt()`'s refusal to write its own ledger went unnoticed — the
|
|
275
|
+
// migration failed for every install that had something to adopt, and the
|
|
276
|
+
// store reported a healthy `adopted: false`. A failed migration is a
|
|
277
|
+
// preference a user set that this launch did not honor, so it is reported
|
|
278
|
+
// where a developer will see it; a store must still open either way.
|
|
279
|
+
if (process.env.AEGISCODE_DEBUG || process.env.AEGIS_HIST_DEBUG) {
|
|
280
|
+
process.stderr.write(`aegis-desktop: shared-preference adoption failed: ${err.message}\n`);
|
|
281
|
+
}
|
|
273
282
|
return { adopted: false, keys: [], reason: `adoption failed: ${err.message}` };
|
|
274
283
|
}
|
|
275
284
|
})();
|
|
@@ -525,26 +534,50 @@ function createSettingsStore({ dir, safeStorage, prefsDir, env } = {}) {
|
|
|
525
534
|
return quickLauncherConfig();
|
|
526
535
|
}
|
|
527
536
|
|
|
528
|
-
// --- Confirm mode:
|
|
529
|
-
// "Confirm before running tools" — the
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
537
|
+
// --- Confirm mode: SHARED preference (AEGISONE ON8, decision D8 rule 1) --
|
|
538
|
+
// "Confirm before running tools" — the switch for the renderer's tool-call
|
|
539
|
+
// approval gate (desktop/lib/local/engine.js gatedExecuteTool). ON (default,
|
|
540
|
+
// and the behaviour shipped so far) means every mutating tool call — exec /
|
|
541
|
+
// writeFile / editFile — is previewed and approved by the user first. OFF
|
|
542
|
+
// means the engine runs them straight through, like a tool the session
|
|
543
|
+
// already allowed. Nothing secret is stored here, so no encryption.
|
|
544
|
+
//
|
|
545
|
+
// This preference MOVED to the shared store (`confirmMode`) because D8 rule 1
|
|
546
|
+
// is "one posture, two faces": a rule set in the window has to be the rule the
|
|
547
|
+
// shell honors. The pre-ON8 `__confirmMode` namespace in `<userData>` is
|
|
548
|
+
// adopted once (client/prefs.js `extractNonSecretHalf`) and still read as a
|
|
549
|
+
// fallback, never written — a second writer is the split-brain this replaced.
|
|
550
|
+
// The gate's polarity is unchanged: ON when unset, in both places, so an
|
|
551
|
+
// install that never touched the switch keeps the gate exactly as it was.
|
|
552
|
+
|
|
553
|
+
function confirmModeState() {
|
|
554
|
+
const shared = loadShared();
|
|
555
|
+
if (typeof shared.confirmMode === 'boolean') {
|
|
556
|
+
return { enabled: shared.confirmMode, source: 'config' };
|
|
557
|
+
}
|
|
558
|
+
const cfg = load()[CONFIRM_MODE_NAMESPACE] || {};
|
|
559
|
+
if (cfg.enabled === undefined) return { enabled: true, source: 'default' };
|
|
560
|
+
return { enabled: Boolean(cfg.enabled), source: 'config' };
|
|
561
|
+
}
|
|
535
562
|
|
|
536
563
|
function getConfirmMode() {
|
|
537
|
-
|
|
538
|
-
// Unset === true: an existing install that never touched the toggle keeps
|
|
539
|
-
// the gate exactly as it was.
|
|
540
|
-
return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
|
|
564
|
+
return confirmModeState().enabled;
|
|
541
565
|
}
|
|
542
566
|
|
|
543
567
|
function setConfirmMode(enabled) {
|
|
568
|
+
prefs.set({ confirmMode: Boolean(enabled) }, { dir: sharedDir });
|
|
569
|
+
// One-way: retire the legacy copy in the same breath, so the posture exists
|
|
570
|
+
// in exactly one place from here on (the same retirement the memory-persist
|
|
571
|
+
// preference below does).
|
|
544
572
|
const data = load();
|
|
545
|
-
data[CONFIRM_MODE_NAMESPACE]
|
|
546
|
-
|
|
547
|
-
|
|
573
|
+
if (data[CONFIRM_MODE_NAMESPACE] !== undefined) {
|
|
574
|
+
delete data[CONFIRM_MODE_NAMESPACE];
|
|
575
|
+
save(data);
|
|
576
|
+
}
|
|
577
|
+
// The boolean, not the state record: main.js's IPC reply is `{ enabled }`
|
|
578
|
+
// and it wraps the return value itself, so widening this would have put an
|
|
579
|
+
// object inside an object on the wire.
|
|
580
|
+
return confirmModeState().enabled;
|
|
548
581
|
}
|
|
549
582
|
|
|
550
583
|
// --- Telemetry: reserved namespace, plain preference, DEFAULT OFF --------
|
|
@@ -771,6 +804,7 @@ function createSettingsStore({ dir, safeStorage, prefsDir, env } = {}) {
|
|
|
771
804
|
setQuickLauncherConfig,
|
|
772
805
|
getConfirmMode,
|
|
773
806
|
setConfirmMode,
|
|
807
|
+
confirmModeState,
|
|
774
808
|
getTelemetryOptIn,
|
|
775
809
|
setTelemetryOptIn,
|
|
776
810
|
getMemoryPersist,
|
package/main.js
CHANGED
|
@@ -1972,8 +1972,10 @@ function createQueueDispatch({
|
|
|
1972
1972
|
draining,
|
|
1973
1973
|
stopping: stopRequested,
|
|
1974
1974
|
defaultCwd: process.cwd(),
|
|
1975
|
-
// What a task with no model of its own
|
|
1976
|
-
//
|
|
1975
|
+
// What a task with no model of its own, on the Aegis Cloud class, actually
|
|
1976
|
+
// runs on — a pooled tier id. byok and local tasks always carry an
|
|
1977
|
+
// explicit model (see autonomous.js modelRefusal), so there is no
|
|
1978
|
+
// equivalent default to report for those classes. The card prints this
|
|
1977
1979
|
// because "which model is this drain spending on" was previously
|
|
1978
1980
|
// unanswerable from the UI.
|
|
1979
1981
|
defaultModel: autonomous.resolveModel({ env }),
|
|
@@ -2008,19 +2010,20 @@ function createQueueDispatch({
|
|
|
2008
2010
|
if (!path.isAbsolute(cwd)) return { error: 'the working directory must be an absolute path' };
|
|
2009
2011
|
if (!isExistingDir(cwd)) return { error: `not a directory: ${cwd}` };
|
|
2010
2012
|
|
|
2013
|
+
const cls = autonomous.normalizeClass(p.class);
|
|
2011
2014
|
const model = String(p.model == null ? '' : p.model).trim().slice(0, 120) || null;
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2014
|
-
//
|
|
2015
|
-
//
|
|
2016
|
-
|
|
2017
|
-
const modelRefusal = model ? autonomous.modelRefusal(model) : '';
|
|
2015
|
+
// Refused HERE so the card gets the reason at the moment of queueing
|
|
2016
|
+
// instead of a red row later. queue.addTask enforces the same (model,
|
|
2017
|
+
// class) rule for the CLI and for hand-written queue files — see
|
|
2018
|
+
// autonomous.js modelRefusal for what each class requires.
|
|
2019
|
+
const modelRefusal = autonomous.modelRefusal(model, cls);
|
|
2018
2020
|
if (modelRefusal) return { error: modelRefusal };
|
|
2019
2021
|
const effortRaw = String(p.effort == null ? '' : p.effort).trim().toLowerCase();
|
|
2020
2022
|
return {
|
|
2021
2023
|
opts: {
|
|
2022
2024
|
task,
|
|
2023
2025
|
cwd,
|
|
2026
|
+
class: cls,
|
|
2024
2027
|
model,
|
|
2025
2028
|
effort: QUEUE_EFFORTS.has(effortRaw) ? effortRaw : null,
|
|
2026
2029
|
workers: clampInt(p.workers, 1, QUEUE_MAX_WORKERS),
|
|
@@ -2029,7 +2032,12 @@ function createQueueDispatch({
|
|
|
2029
2032
|
// it costs about workers+1 full reasoning calls on one task, so the
|
|
2030
2033
|
// card has to ask for it per task. queue.addTask stores the inverse
|
|
2031
2034
|
// (`singlePass`), which is also the field a hand-written queue file
|
|
2032
|
-
// or the CLI uses.
|
|
2035
|
+
// or the CLI uses. Fan-out is an Aegis Cloud mechanism only — a
|
|
2036
|
+
// byok/local task that somehow carried `fanout: true` is NOT honoured
|
|
2037
|
+
// as a fan-out here (autonomous.js's runTask forces it off for any
|
|
2038
|
+
// non-aegis class regardless of this flag), but it is still recorded
|
|
2039
|
+
// as the task's own ask rather than silently rewritten, matching
|
|
2040
|
+
// resolveFanout's "a wrong pick is reported, not rewritten" rule.
|
|
2033
2041
|
singlePass: !(p.fanout === true || p.autonomous === true),
|
|
2034
2042
|
commit: Boolean(p.commit),
|
|
2035
2043
|
source: 'desktop',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.31",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
package/preload.js
CHANGED
|
@@ -423,8 +423,8 @@ const quickLauncher = {
|
|
|
423
423
|
// a real checkout, so that switch is persisted and visible rather than
|
|
424
424
|
// implied — but it is also not hidden from this file's users any more.
|
|
425
425
|
const queue = {
|
|
426
|
-
// Queue one task. `payload` is { task, cwd, model, effort, workers,
|
|
427
|
-
// commit }; main.js range-checks every field and answers
|
|
426
|
+
// Queue one task. `payload` is { task, cwd, class, model, effort, workers,
|
|
427
|
+
// maxRounds, commit }; main.js range-checks every field and answers
|
|
428
428
|
// { ok, item, items, pending, running, runs, … } or { ok: false, reason }.
|
|
429
429
|
enqueue: (payload) => invokeQueue('enqueue', queueTaskFields(payload)),
|
|
430
430
|
// Full state: { items, pending, running, draining, runs, defaultCwd, … }.
|
|
@@ -531,6 +531,11 @@ function queueTaskFields(payload) {
|
|
|
531
531
|
return {
|
|
532
532
|
task: String(p.task == null ? '' : p.task),
|
|
533
533
|
cwd: String(p.cwd == null ? '' : p.cwd),
|
|
534
|
+
// 'aegis' | 'byok' | 'local' — same three classes the main composer uses
|
|
535
|
+
// (window.models.listClasses()). main.js defaults a blank/missing value to
|
|
536
|
+
// 'aegis' (autonomous.normalizeClass), so an older renderer build that
|
|
537
|
+
// never sends this field keeps behaving exactly as it did before.
|
|
538
|
+
class: p.class == null ? null : String(p.class),
|
|
534
539
|
model: p.model == null ? null : String(p.model),
|
|
535
540
|
effort: p.effort == null ? null : String(p.effort),
|
|
536
541
|
workers: p.workers == null ? null : Number(p.workers),
|
package/renderer/app.js
CHANGED
|
@@ -145,6 +145,10 @@ const ELEMENT_IDS = {
|
|
|
145
145
|
send: 'send',
|
|
146
146
|
exploreToggle: 'explore-toggle',
|
|
147
147
|
queueTask: 'queue-task',
|
|
148
|
+
queueClassSelect: 'queue-class-select',
|
|
149
|
+
queueModelSelect: 'queue-model-select',
|
|
150
|
+
queueModelInput: 'queue-model-input',
|
|
151
|
+
queueModelHint: 'queue-model-hint',
|
|
148
152
|
queueCwd: 'queue-cwd',
|
|
149
153
|
queueCommit: 'queue-commit',
|
|
150
154
|
queueEnqueue: 'queue-enqueue',
|
|
@@ -186,6 +190,11 @@ for (const [prop, id] of Object.entries(ELEMENT_IDS)) {
|
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
const CLASS_KEY = 'aegis.class';
|
|
193
|
+
// The queue card's own class pick, kept separate from the chat composer's
|
|
194
|
+
// (CLASS_KEY) — a queued task's backend and the interactive chat's are
|
|
195
|
+
// independent choices, so picking "local" for one must not silently flip
|
|
196
|
+
// the other the next time either picker loads.
|
|
197
|
+
const QUEUE_CLASS_KEY = 'aegis.queueClass';
|
|
189
198
|
const AUTONOMOUS_KEY = 'aegis.autonomous';
|
|
190
199
|
// The budget rung. Applies to every class now that the Max tokens dropdown is
|
|
191
200
|
// gone, so it is no longer stored under the autonomous-mode namespace; the old
|
|
@@ -1463,6 +1472,8 @@ async function enqueueQueueTask() {
|
|
|
1463
1472
|
const res = await queueApi.enqueue({
|
|
1464
1473
|
task,
|
|
1465
1474
|
cwd: els.queueCwd ? els.queueCwd.value.trim() : '',
|
|
1475
|
+
class: els.queueClassSelect ? els.queueClassSelect.value : undefined,
|
|
1476
|
+
model: queueSelectedModel(),
|
|
1466
1477
|
commit: els.queueCommit ? els.queueCommit.checked : false,
|
|
1467
1478
|
});
|
|
1468
1479
|
if (res && res.ok) {
|
|
@@ -1702,11 +1713,17 @@ function formatAutoInterval(ms) {
|
|
|
1702
1713
|
return Number.isInteger(min) ? `every ${min} min` : `every ${min.toFixed(1)} min`;
|
|
1703
1714
|
}
|
|
1704
1715
|
|
|
1705
|
-
/**
|
|
1716
|
+
/**
|
|
1717
|
+
* Paint the auto-drain half of main's answer (`auto.status()`). The mint
|
|
1718
|
+
* (auto-seal) half is NOT painted here: that control lives on the φ(α) panel,
|
|
1719
|
+
* not the queue card, and desktop/renderer/fingerprint.js's own mount() reads
|
|
1720
|
+
* and repaints it independently (loadAutoMint() -> renderAutoMint(), a
|
|
1721
|
+
* closure local to that module, not a global this file could reach even if
|
|
1722
|
+
* it wanted to).
|
|
1723
|
+
*/
|
|
1706
1724
|
function renderAutoStatus(payload) {
|
|
1707
1725
|
if (!payload || typeof payload !== 'object') return;
|
|
1708
1726
|
if (payload.drain) renderAutoDrain(payload.drain);
|
|
1709
|
-
if (typeof renderAutoMint === 'function' && payload.mint) renderAutoMint(payload.mint);
|
|
1710
1727
|
}
|
|
1711
1728
|
|
|
1712
1729
|
/**
|
|
@@ -3585,6 +3602,125 @@ async function loadModels(cls) {
|
|
|
3585
3602
|
}
|
|
3586
3603
|
}
|
|
3587
3604
|
|
|
3605
|
+
// --------------------------------------------------------- queue model class
|
|
3606
|
+
//
|
|
3607
|
+
// The queue card's own class/model picker, independent of the chat
|
|
3608
|
+
// composer's (QUEUE_CLASS_KEY vs CLASS_KEY — see the constant above): a
|
|
3609
|
+
// queued task can run on a different backend than whatever the composer is
|
|
3610
|
+
// currently pointed at. Deliberately a trimmed copy of loadClasses/loadModels
|
|
3611
|
+
// rather than a shared helper — the composer's version carries budget-ceiling
|
|
3612
|
+
// display and the autonomous-toggle visibility it owns, neither of which
|
|
3613
|
+
// belongs to a task that has not run yet.
|
|
3614
|
+
|
|
3615
|
+
/** Mirrors currentModelId() for the queue's own picker. */
|
|
3616
|
+
function queueSelectedModel() {
|
|
3617
|
+
if (els.queueModelInput && !els.queueModelInput.hidden) {
|
|
3618
|
+
const typed = els.queueModelInput.value.trim();
|
|
3619
|
+
if (typed) return typed;
|
|
3620
|
+
}
|
|
3621
|
+
return els.queueModelSelect ? els.queueModelSelect.value : '';
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
/** Fan-out is an Aegis Cloud mechanism only (autonomous.js runTask forces it
|
|
3625
|
+
* off for any other class) — hide the control instead of leaving a checkbox
|
|
3626
|
+
* on screen that would silently do nothing, same reasoning as the composer's
|
|
3627
|
+
* autonomousToggleWrap (AUTONOMOUS_CLASS). */
|
|
3628
|
+
function updateQueueFanoutVisibility(cls) {
|
|
3629
|
+
const wrap = els.queueFanout && els.queueFanout.closest('label');
|
|
3630
|
+
if (wrap) wrap.hidden = cls !== 'aegis';
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
async function loadQueueModels(cls) {
|
|
3634
|
+
if (!els.queueModelSelect) return;
|
|
3635
|
+
updateQueueFanoutVisibility(cls);
|
|
3636
|
+
|
|
3637
|
+
els.queueModelSelect.hidden = false;
|
|
3638
|
+
els.queueModelSelect.disabled = false;
|
|
3639
|
+
if (els.queueModelInput) {
|
|
3640
|
+
els.queueModelInput.hidden = true;
|
|
3641
|
+
els.queueModelInput.value = '';
|
|
3642
|
+
}
|
|
3643
|
+
if (els.queueModelHint) els.queueModelHint.textContent = '';
|
|
3644
|
+
|
|
3645
|
+
els.queueModelSelect.innerHTML = '';
|
|
3646
|
+
if (cls === 'aegis') {
|
|
3647
|
+
// Only this class has a real default to fall back to (resolveModel fills
|
|
3648
|
+
// in the pooled brain) — byok/local tasks must name a model, so neither
|
|
3649
|
+
// gets this option.
|
|
3650
|
+
const auto = document.createElement('option');
|
|
3651
|
+
auto.value = '';
|
|
3652
|
+
auto.textContent = 'server default (auto)';
|
|
3653
|
+
els.queueModelSelect.appendChild(auto);
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
try {
|
|
3657
|
+
const data = await models.listModels(cls);
|
|
3658
|
+
const list = Array.isArray(data && data.models) ? data.models : [];
|
|
3659
|
+
const needsKey = Boolean(data && data.needsKey);
|
|
3660
|
+
const needsDaemon = Boolean(data && data.needsDaemon);
|
|
3661
|
+
const listed = !(data && data.listed === false);
|
|
3662
|
+
if (els.queueModelInput) {
|
|
3663
|
+
els.queueModelInput.hidden = !(cls === 'local' && (needsDaemon || !listed));
|
|
3664
|
+
}
|
|
3665
|
+
for (const m of list) {
|
|
3666
|
+
const opt = document.createElement('option');
|
|
3667
|
+
opt.value = m.id;
|
|
3668
|
+
opt.textContent = m.label || m.id;
|
|
3669
|
+
els.queueModelSelect.appendChild(opt);
|
|
3670
|
+
}
|
|
3671
|
+
if (!els.queueModelHint) return;
|
|
3672
|
+
if (needsKey) {
|
|
3673
|
+
els.queueModelHint.textContent =
|
|
3674
|
+
'Connect this class in the Model card above (API key / provider key), then Refresh.';
|
|
3675
|
+
} else if (needsDaemon) {
|
|
3676
|
+
els.queueModelHint.textContent =
|
|
3677
|
+
`No local model server at ${(data && data.baseURL) || 'the configured address'} — ` +
|
|
3678
|
+
'start it, then reopen this picker.';
|
|
3679
|
+
} else if (cls === 'local' && !listed) {
|
|
3680
|
+
els.queueModelHint.textContent =
|
|
3681
|
+
'That address answered but listed no models — type the model tag above.';
|
|
3682
|
+
} else if (!list.length) {
|
|
3683
|
+
els.queueModelHint.textContent =
|
|
3684
|
+
cls === 'local'
|
|
3685
|
+
? 'No models on this server yet — pull one first (ollama pull llama3.2).'
|
|
3686
|
+
: cls === 'aegis'
|
|
3687
|
+
? "No models to pin — this runs the pooled route on the server's own default."
|
|
3688
|
+
: 'No models listed.';
|
|
3689
|
+
} else if (cls === 'byok' && data && data.needsProviderKey) {
|
|
3690
|
+
els.queueModelHint.textContent =
|
|
3691
|
+
'A provider key is required before a queued task on this model can run — add one in the Model card above.';
|
|
3692
|
+
}
|
|
3693
|
+
} catch (err) {
|
|
3694
|
+
if (els.queueModelHint) {
|
|
3695
|
+
els.queueModelHint.textContent = `listModels failed: ${err && err.message ? err.message : err}`;
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
async function loadQueueClasses() {
|
|
3701
|
+
if (!els.queueClassSelect) return;
|
|
3702
|
+
let classOptions = [];
|
|
3703
|
+
try {
|
|
3704
|
+
classOptions = (await models.listClasses()) || [];
|
|
3705
|
+
} catch (err) {
|
|
3706
|
+
classOptions = [];
|
|
3707
|
+
if (els.queueModelHint) {
|
|
3708
|
+
els.queueModelHint.textContent = `listClasses failed: ${err && err.message ? err.message : err}`;
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
els.queueClassSelect.innerHTML = '';
|
|
3712
|
+
for (const c of classOptions) {
|
|
3713
|
+
const opt = document.createElement('option');
|
|
3714
|
+
opt.value = c.class;
|
|
3715
|
+
opt.textContent = c.configured ? c.label : `${c.label} (not configured)`;
|
|
3716
|
+
els.queueClassSelect.appendChild(opt);
|
|
3717
|
+
}
|
|
3718
|
+
const saved = localStorage.getItem(QUEUE_CLASS_KEY);
|
|
3719
|
+
if (saved && classOptions.some((c) => c.class === saved)) {
|
|
3720
|
+
els.queueClassSelect.value = saved;
|
|
3721
|
+
}
|
|
3722
|
+
await loadQueueModels(els.queueClassSelect.value);
|
|
3723
|
+
}
|
|
3588
3724
|
|
|
3589
3725
|
// -------------------------------------------------------------- settings pane
|
|
3590
3726
|
|
|
@@ -4756,10 +4892,17 @@ async function init() {
|
|
|
4756
4892
|
// A worker's frames are a status line, never transcript entries (that
|
|
4757
4893
|
// separation is main.js's own progress channel, not aegis:chatDelta).
|
|
4758
4894
|
if (typeof queueApi.onProgress === 'function') queueApi.onProgress(renderQueueProgress);
|
|
4895
|
+
if (els.queueClassSelect) {
|
|
4896
|
+
els.queueClassSelect.addEventListener('change', () => {
|
|
4897
|
+
localStorage.setItem(QUEUE_CLASS_KEY, els.queueClassSelect.value);
|
|
4898
|
+
loadQueueModels(els.queueClassSelect.value);
|
|
4899
|
+
});
|
|
4900
|
+
}
|
|
4759
4901
|
// Paint the card once, from state that already exists on disk. A list, not a
|
|
4760
4902
|
// drain: queueing a task in another window (or from the CLI) and opening
|
|
4761
4903
|
// this one must show that task, and must not run it.
|
|
4762
4904
|
await loadQueueState();
|
|
4905
|
+
await loadQueueClasses();
|
|
4763
4906
|
}
|
|
4764
4907
|
|
|
4765
4908
|
// ── the two automation switches ──────────────────────────────────────────
|
package/renderer/index.html
CHANGED
|
@@ -277,6 +277,24 @@
|
|
|
277
277
|
rows="2"
|
|
278
278
|
placeholder="Queue a task to run unattended…"
|
|
279
279
|
></textarea>
|
|
280
|
+
<!-- Which backend this task runs on — the same three classes
|
|
281
|
+
(window.models.listClasses()) and the same per-class model list
|
|
282
|
+
(listModels(cls)) the chat composer's #class-select/#model-select
|
|
283
|
+
use. Aegis Cloud can leave the model on "server default"; byok and
|
|
284
|
+
local tasks must name one (autonomous.js modelRefusal refuses a
|
|
285
|
+
blank at the door for those two). -->
|
|
286
|
+
<div class="api-key-row">
|
|
287
|
+
<select id="queue-class-select" title="Which backend this task runs on."></select>
|
|
288
|
+
<select id="queue-model-select" title="The model for the picked class."></select>
|
|
289
|
+
</div>
|
|
290
|
+
<input
|
|
291
|
+
type="text"
|
|
292
|
+
id="queue-model-input"
|
|
293
|
+
placeholder="model tag — e.g. llama3.2:3b"
|
|
294
|
+
autocomplete="off"
|
|
295
|
+
hidden
|
|
296
|
+
/>
|
|
297
|
+
<p class="hint" id="queue-model-hint"></p>
|
|
280
298
|
<div class="api-key-row">
|
|
281
299
|
<input
|
|
282
300
|
type="text"
|
package/vendor/prefs.js
CHANGED
|
@@ -69,6 +69,11 @@ const SHARED_PREF_KEYS = Object.freeze([
|
|
|
69
69
|
'effort',
|
|
70
70
|
'vim',
|
|
71
71
|
'memoryPersist',
|
|
72
|
+
// ON8 / D8 rule 1: the confirmation gate is a POSTURE, not a surface. The
|
|
73
|
+
// window's "confirm before running tools" switch and the shell's rule set
|
|
74
|
+
// answer the same question ("may this call run unattended?"), so the switch
|
|
75
|
+
// lives beside the rules that gate answers, in the one store both hosts read.
|
|
76
|
+
'confirmMode',
|
|
72
77
|
]);
|
|
73
78
|
|
|
74
79
|
/**
|
|
@@ -79,6 +84,15 @@ const SHARED_PREF_KEYS = Object.freeze([
|
|
|
79
84
|
*/
|
|
80
85
|
const LEGACY_MEMORY_PERSIST_NAMESPACE = '__memoryPersist';
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The second legacy namespace this store retires (ON8): the window's
|
|
89
|
+
* `__confirmMode: { enabled }` becomes the shared `confirmMode` boolean. Same
|
|
90
|
+
* rule, same reason as memory-persist above — and it is the one that closes
|
|
91
|
+
* D8's rule 1 in the direction that used to be impossible: a posture set in
|
|
92
|
+
* the window is now the posture the shell reads.
|
|
93
|
+
*/
|
|
94
|
+
const LEGACY_CONFIRM_MODE_NAMESPACE = '__confirmMode';
|
|
95
|
+
|
|
82
96
|
/**
|
|
83
97
|
* Key names that mean "secret" wherever they appear in the tree. Deliberately
|
|
84
98
|
* conservative: `keyMask`-style *previews* never reach this file (they are not
|
|
@@ -233,7 +247,7 @@ function remove(key, opts) {
|
|
|
233
247
|
/**
|
|
234
248
|
* The non-secret half of a legacy `<userData>/settings.json`, in the shared
|
|
235
249
|
* store's spellings. Only namespaces THIS store owns are extracted: the other
|
|
236
|
-
* app-level namespaces (`
|
|
250
|
+
* app-level namespaces (`__avatar`, `__telemetry`,
|
|
237
251
|
* `__autoDrain`, `__autoMint`, `__quickLauncher`) stay host-local until their
|
|
238
252
|
* own phase moves them, and the provider rows are never here at all because a
|
|
239
253
|
* provider row is where a key lives.
|
|
@@ -244,6 +258,13 @@ function extractNonSecretHalf(legacy) {
|
|
|
244
258
|
if (src && typeof src === 'object' && typeof src.enabled === 'boolean') {
|
|
245
259
|
out.memoryPersist = src.enabled;
|
|
246
260
|
}
|
|
261
|
+
// ON8: the confirmation gate adopts the same way. `enabled: true` means the
|
|
262
|
+
// gate is UP, which is the shared `confirmMode: true`; the shared key keeps
|
|
263
|
+
// the window's own polarity so an adopting install cannot flip its gate.
|
|
264
|
+
const gate = (legacy || {})[LEGACY_CONFIRM_MODE_NAMESPACE];
|
|
265
|
+
if (gate && typeof gate === 'object' && typeof gate.enabled === 'boolean') {
|
|
266
|
+
out.confirmMode = gate.enabled;
|
|
267
|
+
}
|
|
247
268
|
return out;
|
|
248
269
|
}
|
|
249
270
|
|
|
@@ -290,10 +311,19 @@ function adopt(opts) {
|
|
|
290
311
|
}
|
|
291
312
|
const patch = {};
|
|
292
313
|
for (const k of keys) patch[k] = half[k];
|
|
314
|
+
// `names`, NOT `keys` (AEGISONE ON8): this ledger is written through `write()`
|
|
315
|
+
// below, so it is scanned like everything else — and `SECRET_KEY_RE` matches
|
|
316
|
+
// `key`/`keys` by design, because a preference named `keys` IS how key material
|
|
317
|
+
// would smuggle itself into a file the desktop and the shell both read. Spelled
|
|
318
|
+
// `keys`, this field made `assertNoSecrets` refuse the store's own write, so
|
|
319
|
+
// EVERY adoption with something to adopt threw — the whole pre-D5 migration was
|
|
320
|
+
// dead, silently (settings.js's best-effort catch turned the throw into "no
|
|
321
|
+
// adoption" and no one was told). The scanner is the control here, not the bug;
|
|
322
|
+
// a field name is free to change.
|
|
293
323
|
patch.adopted = {
|
|
294
324
|
from: legacyFile,
|
|
295
325
|
at: new Date().toISOString(),
|
|
296
|
-
|
|
326
|
+
names: keys.slice().sort(),
|
|
297
327
|
};
|
|
298
328
|
write({ ...current, ...patch }, o);
|
|
299
329
|
return { adopted: true, keys: keys.slice().sort(), from: legacyFile };
|