aegis-desktop 1.0.13 → 1.0.15
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/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/vendor/prefs.js
CHANGED
|
@@ -1,353 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
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
|
-
* prefs.js — the ONE shared, non-secret preference store (AEGISONE, decision
|
|
10
|
-
* D5: "one store for preferences; secrets stay where the OS can decrypt them").
|
|
11
|
-
*
|
|
12
|
-
* Both faces read and write this file:
|
|
13
|
-
*
|
|
14
|
-
* the window desktop/lib/settings.js (the memory-persist preference, and the
|
|
15
|
-
* CLI-key accessors `getSharedPref`/
|
|
16
|
-
* `setSharedPref` the store exposes)
|
|
17
|
-
* the shell cli/src/config.js (theme, model/class pin, effort, vim,
|
|
18
|
-
* memoryPersist)
|
|
19
|
-
*
|
|
20
|
-
* and `desktop/lib/sync/persist-gate.js` reads it directly, in the main process,
|
|
21
|
-
* before it opens a socket.
|
|
22
|
-
*
|
|
23
|
-
* WHY A THIRD FILE, when the repo already has credentials.json and sessions.json
|
|
24
|
-
* in the same directory: those two are a *secret* store and a *content* store.
|
|
25
|
-
* This one is neither. Splitting the desktop's single `<userData>/settings.json`
|
|
26
|
-
* along the line D5 already drew for sessions and credentials is what makes the
|
|
27
|
-
* split honest:
|
|
28
|
-
*
|
|
29
|
-
* $AEGISCODE_HOME/preferences.json non-secret preferences (this file)
|
|
30
|
-
* <userData>/settings.json host-local: `safeStorage` blobs (a plain
|
|
31
|
-
* Node process, i.e. the CLI, cannot decrypt
|
|
32
|
-
* those on Linux) + machine/UI state
|
|
33
|
-
*
|
|
34
|
-
* The boundary is enforced, not documented: `set()` refuses to write anything
|
|
35
|
-
* that looks like key material (`scanForSecrets`/`assertNoSecrets` below), so a
|
|
36
|
-
* caller that tries to park an API key in the shared store gets a thrown error
|
|
37
|
-
* rather than a plaintext secret in a file the other host reads. A preference
|
|
38
|
-
* store that *can* carry a key is a second place to leak one; this one cannot.
|
|
39
|
-
*
|
|
40
|
-
* Adoption of a pre-unification `<userData>/settings.json` is one-way, once and
|
|
41
|
-
* idempotent — the pattern `client/session-store.js` already proves and
|
|
42
|
-
* `test/shared-store.test.mjs` property D already asserts. There is no merge
|
|
43
|
-
* loop and no last-writer-wins ping-pong: shared values are never copied back
|
|
44
|
-
* into the legacy file, and a second adoption attempt is refused.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
const fs = require('node:fs');
|
|
48
|
-
const path = require('node:path');
|
|
49
|
-
|
|
50
|
-
// The data dir is defined once, in the shared credential module — the same rule
|
|
51
|
-
// cli/src/config.js and desktop/lib/sync/sessions.js follow, so the two faces
|
|
52
|
-
// cannot drift about where the store lives (`$AEGISCODE_HOME`, else
|
|
53
|
-
// `~/.aegiscode`).
|
|
54
|
-
const { aegisHome } = require('./credentials.js');
|
|
55
|
-
|
|
56
|
-
const PREFS_FILE = 'preferences.json';
|
|
57
|
-
const PREFS_VERSION = 1;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The non-secret preference keys the shell's `config.js` keeps in the shared
|
|
61
|
-
* store (everything else in its config — `updateCheck`, `lastCwd`, a legacy
|
|
62
|
-
* `provider`/`baseURL` row — stays in `config.json`, which is host-local).
|
|
63
|
-
*
|
|
64
|
-
* This list is also the access-control list for the desktop's generic
|
|
65
|
-
* `getSharedPref`/`setSharedPref`, so the window can read exactly the
|
|
66
|
-
* preferences the shell owns and not reach into anyone else's state.
|
|
67
|
-
*/
|
|
68
|
-
const SHARED_PREF_KEYS = Object.freeze([
|
|
69
|
-
'themeIndex',
|
|
70
|
-
'model',
|
|
71
|
-
'modelClass',
|
|
72
|
-
'currentModelId',
|
|
73
|
-
'models',
|
|
74
|
-
'effort',
|
|
75
|
-
'vim',
|
|
76
|
-
'memoryPersist',
|
|
77
|
-
// ON8 / D8 rule 1: the confirmation gate is a POSTURE, not a surface. The
|
|
78
|
-
// window's "confirm before running tools" switch and the shell's rule set
|
|
79
|
-
// answer the same question ("may this call run unattended?"), so the switch
|
|
80
|
-
// lives beside the rules that gate answers, in the one store both hosts read.
|
|
81
|
-
'confirmMode',
|
|
82
|
-
]);
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The legacy desktop namespace D5 retires: `<userData>/settings.json`'s
|
|
86
|
-
* `__memoryPersist: { enabled }` becomes the shared store's `memoryPersist`
|
|
87
|
-
* boolean — the shell's own spelling (`cli/src/config.js` `MEMORY_PERSIST_KEY`),
|
|
88
|
-
* so one preference has one representation instead of two that could disagree.
|
|
89
|
-
*/
|
|
90
|
-
const LEGACY_MEMORY_PERSIST_NAMESPACE = '__memoryPersist';
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* The second legacy namespace this store retires (ON8): the window's
|
|
94
|
-
* `__confirmMode: { enabled }` becomes the shared `confirmMode` boolean. Same
|
|
95
|
-
* rule, same reason as memory-persist above — and it is the one that closes
|
|
96
|
-
* D8's rule 1 in the direction that used to be impossible: a posture set in
|
|
97
|
-
* the window is now the posture the shell reads.
|
|
98
|
-
*/
|
|
99
|
-
const LEGACY_CONFIRM_MODE_NAMESPACE = '__confirmMode';
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Key names that mean "secret" wherever they appear in the tree. Deliberately
|
|
103
|
-
* conservative: `keyMask`-style *previews* never reach this file (they are not
|
|
104
|
-
* preferences), and no legitimate preference is named `token`/`secret`/`auth`.
|
|
105
|
-
*/
|
|
106
|
-
const SECRET_KEY_RE =
|
|
107
|
-
/(^|[^a-z0-9])(api[_-]?key|apikey|key|keys|token|secret|password|passwd|credential|credentials|authorization|bearer|private[_-]?key|access[_-]?key|session[_-]?key)([^a-z0-9]|$)/i;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Values that are key material whatever they are called. Every shape here is a
|
|
111
|
-
* real prefix this product's hosts mint or accept: AEGIS Cloud keys
|
|
112
|
-
* (`aegis_…`), OpenAI/BYOK keys (`sk-…`), GitHub/Anthropic strings (`ghp_`,
|
|
113
|
-
* `sk-ant-…`), JWTs (`eyJ…`, which is what an OAuth-ish provider returns), and
|
|
114
|
-
* a bare long base64 blob (which is what Electron's `safeStorage` ciphertext —
|
|
115
|
-
* and a base64-encoded key, the exact fallback `settings.js` uses when
|
|
116
|
-
* `safeStorage` is unavailable — looks like).
|
|
117
|
-
*/
|
|
118
|
-
const SECRET_VALUE_RES = [
|
|
119
|
-
/\baegis_[A-Za-z0-9_\-]{12,}/,
|
|
120
|
-
/\bsk-[A-Za-z0-9_\-]{12,}/,
|
|
121
|
-
/\bghp_[A-Za-z0-9_\-]{20,}/,
|
|
122
|
-
/\bxox[baprs]-[A-Za-z0-9\-]{10,}/,
|
|
123
|
-
/\beyJ[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{8,}/,
|
|
124
|
-
/^[A-Za-z0-9+/]{48,}={0,2}$/,
|
|
125
|
-
];
|
|
126
|
-
|
|
127
|
-
/** The shared dir: an explicit `dir`, else `$AEGISCODE_HOME`/`~/.aegiscode`. */
|
|
128
|
-
function prefsDir(opts) {
|
|
129
|
-
const o = opts || {};
|
|
130
|
-
if (o.dir && String(o.dir).trim()) return path.resolve(String(o.dir).trim());
|
|
131
|
-
return aegisHome(o.env);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The shared file. `prefsFile({ dir, env })`. */
|
|
135
|
-
function prefsFile(opts) {
|
|
136
|
-
const o = opts || {};
|
|
137
|
-
if (o.file && String(o.file).trim()) return path.resolve(String(o.file).trim());
|
|
138
|
-
return path.join(prefsDir(o), PREFS_FILE);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Every finding in `value` that looks like key material, as dotted paths.
|
|
143
|
-
* An empty array means "nothing key-shaped in here" — the property the exit
|
|
144
|
-
* criteria assert about the shared file, with a negative control that shows the
|
|
145
|
-
* scan can fail.
|
|
146
|
-
*/
|
|
147
|
-
function scanForSecrets(value, atPath = '', found = []) {
|
|
148
|
-
if (value == null) return found;
|
|
149
|
-
if (typeof value === 'string') {
|
|
150
|
-
if (SECRET_VALUE_RES.some((re) => re.test(value))) found.push(atPath || '(root)');
|
|
151
|
-
return found;
|
|
152
|
-
}
|
|
153
|
-
if (typeof value !== 'object') return found;
|
|
154
|
-
if (Array.isArray(value)) {
|
|
155
|
-
value.forEach((v, i) => scanForSecrets(v, `${atPath}[${i}]`, found));
|
|
156
|
-
return found;
|
|
157
|
-
}
|
|
158
|
-
for (const [k, v] of Object.entries(value)) {
|
|
159
|
-
const here = atPath ? `${atPath}.${k}` : k;
|
|
160
|
-
// An empty/absent value is not key material — `{ key: null }` is how this
|
|
161
|
-
// repo clears a stored credential, and refusing that would be noise.
|
|
162
|
-
if (SECRET_KEY_RE.test(k) && v != null && v !== '' && v !== false) {
|
|
163
|
-
found.push(here);
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
scanForSecrets(v, here, found);
|
|
167
|
-
}
|
|
168
|
-
return found;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Throw when `value` carries key material. The writer's fail-closed gate. */
|
|
172
|
-
function assertNoSecrets(value, what = 'shared preference store') {
|
|
173
|
-
const found = scanForSecrets(value);
|
|
174
|
-
if (found.length) {
|
|
175
|
-
throw new Error(
|
|
176
|
-
`${what} must not carry key material — refusing to write (found: ${found.join(', ')}). ` +
|
|
177
|
-
'Secrets belong in the host-local credential store, where the OS can protect them.'
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
return value;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function readJson(file, fallback) {
|
|
184
|
-
try {
|
|
185
|
-
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
186
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback;
|
|
187
|
-
} catch {
|
|
188
|
-
return fallback;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Read the shared store. Never throws: a corrupt file reads as empty. */
|
|
193
|
-
function load(opts) {
|
|
194
|
-
return readJson(prefsFile(opts), {});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Write the shared store atomically and 0600.
|
|
199
|
-
*
|
|
200
|
-
* 0600 rather than 0644 even though nothing secret belongs here: the file
|
|
201
|
-
* decides whether a user's turns are persisted to the cloud (`memoryPersist`)
|
|
202
|
-
* and which provider row the local class dials, so a writable copy is another
|
|
203
|
-
* local account's way to change how this host behaves. The mode is the control;
|
|
204
|
-
* the scan above is the boundary.
|
|
205
|
-
*/
|
|
206
|
-
function write(next, opts) {
|
|
207
|
-
assertNoSecrets(next);
|
|
208
|
-
const file = prefsFile(opts);
|
|
209
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
210
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
211
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
212
|
-
fs.renameSync(tmp, file);
|
|
213
|
-
try {
|
|
214
|
-
fs.chmodSync(file, 0o600);
|
|
215
|
-
} catch {
|
|
216
|
-
/* Windows: POSIX modes are a no-op, ACLs are the OS's business */
|
|
217
|
-
}
|
|
218
|
-
return next;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Merge a patch into the shared store and return the merged view. */
|
|
222
|
-
function set(patch, opts) {
|
|
223
|
-
const next = { ...load(opts), ...(patch || {}) };
|
|
224
|
-
// `undefined` in a patch is how callers retire a key (JSON.stringify drops
|
|
225
|
-
// it), so drop those before the scan and the write.
|
|
226
|
-
for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k];
|
|
227
|
-
if (next.version === undefined) next.version = PREFS_VERSION;
|
|
228
|
-
return write(next, opts);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** One shared preference value, or `fallback`. */
|
|
232
|
-
function get(key, opts, fallback) {
|
|
233
|
-
const v = load(opts)[key];
|
|
234
|
-
return v === undefined ? fallback : v;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** True when the store holds an explicit value for `key`. */
|
|
238
|
-
function has(key, opts) {
|
|
239
|
-
return load(opts)[key] !== undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Retire a key. Confined here (read-modify-write) so the two faces never
|
|
244
|
-
* clobber each other's namespaces with a whole-file overwrite.
|
|
245
|
-
*/
|
|
246
|
-
function remove(key, opts) {
|
|
247
|
-
const next = load(opts);
|
|
248
|
-
delete next[key];
|
|
249
|
-
return write(next, opts);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* The non-secret half of a legacy `<userData>/settings.json`, in the shared
|
|
254
|
-
* store's spellings. Only namespaces THIS store owns are extracted: the other
|
|
255
|
-
* app-level namespaces (`__avatar`, `__telemetry`,
|
|
256
|
-
* `__autoDrain`, `__autoMint`, `__quickLauncher`) stay host-local until their
|
|
257
|
-
* own phase moves them, and the provider rows are never here at all because a
|
|
258
|
-
* provider row is where a key lives.
|
|
259
|
-
*/
|
|
260
|
-
function extractNonSecretHalf(legacy) {
|
|
261
|
-
const out = {};
|
|
262
|
-
const src = (legacy || {})[LEGACY_MEMORY_PERSIST_NAMESPACE];
|
|
263
|
-
if (src && typeof src === 'object' && typeof src.enabled === 'boolean') {
|
|
264
|
-
out.memoryPersist = src.enabled;
|
|
265
|
-
}
|
|
266
|
-
// ON8: the confirmation gate adopts the same way. `enabled: true` means the
|
|
267
|
-
// gate is UP, which is the shared `confirmMode: true`; the shared key keeps
|
|
268
|
-
// the window's own polarity so an adopting install cannot flip its gate.
|
|
269
|
-
const gate = (legacy || {})[LEGACY_CONFIRM_MODE_NAMESPACE];
|
|
270
|
-
if (gate && typeof gate === 'object' && typeof gate.enabled === 'boolean') {
|
|
271
|
-
out.confirmMode = gate.enabled;
|
|
272
|
-
}
|
|
273
|
-
return out;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* One-way, once, idempotent adoption of the legacy desktop store's non-secret
|
|
278
|
-
* half.
|
|
279
|
-
*
|
|
280
|
-
* `adopt({ dir, env, legacyDir })`:
|
|
281
|
-
* - `dir`/`env` locate the SHARED store;
|
|
282
|
-
* - `legacyDir` is the old settings directory (Electron's `<userData>`; main
|
|
283
|
-
* passes the same dir its store was built with).
|
|
284
|
-
*
|
|
285
|
-
* One-way: the legacy file is read, never written — a preference that moved to
|
|
286
|
-
* the shared store is not mirrored back. Once: a key the shared store already
|
|
287
|
-
* carries is never overwritten, so a second run has nothing left to do and
|
|
288
|
-
* returns `adopted:false` without touching the file. That is what makes it
|
|
289
|
-
* idempotent rather than a periodic merge.
|
|
290
|
-
*/
|
|
291
|
-
function adopt(opts) {
|
|
292
|
-
const o = opts || {};
|
|
293
|
-
const shared = prefsFile(o);
|
|
294
|
-
const legacyDir = o.legacyDir;
|
|
295
|
-
const legacyFile = legacyDir ? path.join(String(legacyDir), 'settings.json') : null;
|
|
296
|
-
if (legacyFile && path.resolve(legacyFile) === shared) {
|
|
297
|
-
return { adopted: false, keys: [], from: legacyFile, reason: 'shared store is the legacy file' };
|
|
298
|
-
}
|
|
299
|
-
if (!legacyFile || !fs.existsSync(legacyFile)) {
|
|
300
|
-
return { adopted: false, keys: [], from: legacyFile, reason: 'no legacy settings file' };
|
|
301
|
-
}
|
|
302
|
-
const current = load(o);
|
|
303
|
-
const half = extractNonSecretHalf(readJson(legacyFile, {}));
|
|
304
|
-
// One-way means missing-only: an explicit shared value outranks the legacy
|
|
305
|
-
// one and is never clobbered by it.
|
|
306
|
-
const keys = Object.keys(half).filter((k) => current[k] === undefined);
|
|
307
|
-
if (!keys.length) {
|
|
308
|
-
return {
|
|
309
|
-
adopted: false,
|
|
310
|
-
keys: [],
|
|
311
|
-
from: legacyFile,
|
|
312
|
-
reason: Object.keys(half).length
|
|
313
|
-
? 'shared store already carries these preferences'
|
|
314
|
-
: 'legacy store has no shared preferences',
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
const patch = {};
|
|
318
|
-
for (const k of keys) patch[k] = half[k];
|
|
319
|
-
// `names`, NOT `keys` (AEGISONE ON8): this ledger is written through `write()`
|
|
320
|
-
// below, so it is scanned like everything else — and `SECRET_KEY_RE` matches
|
|
321
|
-
// `key`/`keys` by design, because a preference named `keys` IS how key material
|
|
322
|
-
// would smuggle itself into a file the desktop and the shell both read. Spelled
|
|
323
|
-
// `keys`, this field made `assertNoSecrets` refuse the store's own write, so
|
|
324
|
-
// EVERY adoption with something to adopt threw — the whole pre-D5 migration was
|
|
325
|
-
// dead, silently (settings.js's best-effort catch turned the throw into "no
|
|
326
|
-
// adoption" and no one was told). The scanner is the control here, not the bug;
|
|
327
|
-
// a field name is free to change.
|
|
328
|
-
patch.adopted = {
|
|
329
|
-
from: legacyFile,
|
|
330
|
-
at: new Date().toISOString(),
|
|
331
|
-
names: keys.slice().sort(),
|
|
332
|
-
};
|
|
333
|
-
write({ ...current, ...patch }, o);
|
|
334
|
-
return { adopted: true, keys: keys.slice().sort(), from: legacyFile };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
module.exports = {
|
|
338
|
-
PREFS_FILE,
|
|
339
|
-
PREFS_VERSION,
|
|
340
|
-
SHARED_PREF_KEYS,
|
|
341
|
-
LEGACY_MEMORY_PERSIST_NAMESPACE,
|
|
342
|
-
prefsDir,
|
|
343
|
-
prefsFile,
|
|
344
|
-
load,
|
|
345
|
-
set,
|
|
346
|
-
get,
|
|
347
|
-
has,
|
|
348
|
-
remove,
|
|
349
|
-
scanForSecrets,
|
|
350
|
-
assertNoSecrets,
|
|
351
|
-
extractNonSecretHalf,
|
|
352
|
-
adopt,
|
|
353
|
-
};
|
|
3
|
+
"use strict";const fs=require("node:fs"),path=require("node:path"),{aegisHome:aegisHome}=require("./credentials.js"),PREFS_FILE="preferences.json",PREFS_VERSION=1,SHARED_PREF_KEYS=Object.freeze(["themeIndex","model","modelClass","currentModelId","models","effort","vim","memoryPersist","confirmMode"]),LEGACY_MEMORY_PERSIST_NAMESPACE="__memoryPersist",LEGACY_CONFIRM_MODE_NAMESPACE="__confirmMode",SECRET_KEY_RE=/(^|[^a-z0-9])(api[_-]?key|apikey|key|keys|token|secret|password|passwd|credential|credentials|authorization|bearer|private[_-]?key|access[_-]?key|session[_-]?key)([^a-z0-9]|$)/i,SECRET_VALUE_RES=[/\baegis_[A-Za-z0-9_\-]{12,}/,/\bsk-[A-Za-z0-9_\-]{12,}/,/\bghp_[A-Za-z0-9_\-]{20,}/,/\bxox[baprs]-[A-Za-z0-9\-]{10,}/,/\beyJ[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{8,}/,/^[A-Za-z0-9+/]{48,}={0,2}$/];function prefsDir(e){const r=e||{};return r.dir&&String(r.dir).trim()?path.resolve(String(r.dir).trim()):aegisHome(r.env)}function prefsFile(e){const r=e||{};return r.file&&String(r.file).trim()?path.resolve(String(r.file).trim()):path.join(prefsDir(r),PREFS_FILE)}function scanForSecrets(e,r="",t=[]){if(null==e)return t;if("string"==typeof e)return SECRET_VALUE_RES.some((r=>r.test(e)))&&t.push(r||"(root)"),t;if("object"!=typeof e)return t;if(Array.isArray(e))return e.forEach(((e,o)=>scanForSecrets(e,`${r}[${o}]`,t))),t;for(const[o,s]of Object.entries(e)){const e=r?`${r}.${o}`:o;SECRET_KEY_RE.test(o)&&null!=s&&""!==s&&!1!==s?t.push(e):scanForSecrets(s,e,t)}return t}function assertNoSecrets(e,r="shared preference store"){const t=scanForSecrets(e);if(t.length)throw new Error(`${r} must not carry key material — refusing to write (found: ${t.join(", ")}). Secrets belong in the host-local credential store, where the OS can protect them.`);return e}function readJson(e,r){try{const t=JSON.parse(fs.readFileSync(e,"utf8"));return t&&"object"==typeof t&&!Array.isArray(t)?t:r}catch{return r}}function load(e){return readJson(prefsFile(e),{})}function write(e,r){assertNoSecrets(e);const t=prefsFile(r);fs.mkdirSync(path.dirname(t),{recursive:!0});const o=`${t}.tmp-${process.pid}`;fs.writeFileSync(o,JSON.stringify(e,null,2)+"\n",{mode:384}),fs.renameSync(o,t);try{fs.chmodSync(t,384)}catch{}return e}function set(e,r){const t={...load(r),...e||{}};for(const e of Object.keys(t))void 0===t[e]&&delete t[e];return void 0===t.version&&(t.version=1),write(t,r)}function get(e,r,t){const o=load(r)[e];return void 0===o?t:o}function has(e,r){return void 0!==load(r)[e]}function remove(e,r){const t=load(r);return delete t[e],write(t,r)}function extractNonSecretHalf(e){const r={},t=(e||{})[LEGACY_MEMORY_PERSIST_NAMESPACE];t&&"object"==typeof t&&"boolean"==typeof t.enabled&&(r.memoryPersist=t.enabled);const o=(e||{})[LEGACY_CONFIRM_MODE_NAMESPACE];return o&&"object"==typeof o&&"boolean"==typeof o.enabled&&(r.confirmMode=o.enabled),r}function adopt(e){const r=e||{},t=prefsFile(r),o=r.legacyDir,s=o?path.join(String(o),"settings.json"):null;if(s&&path.resolve(s)===t)return{adopted:!1,keys:[],from:s,reason:"shared store is the legacy file"};if(!s||!fs.existsSync(s))return{adopted:!1,keys:[],from:s,reason:"no legacy settings file"};const n=load(r),a=extractNonSecretHalf(readJson(s,{})),i=Object.keys(a).filter((e=>void 0===n[e]));if(!i.length)return{adopted:!1,keys:[],from:s,reason:Object.keys(a).length?"shared store already carries these preferences":"legacy store has no shared preferences"};const c={};for(const e of i)c[e]=a[e];return c.adopted={from:s,at:(new Date).toISOString(),names:i.slice().sort()},write({...n,...c},r),{adopted:!0,keys:i.slice().sort(),from:s}}module.exports={PREFS_FILE:PREFS_FILE,PREFS_VERSION:1,SHARED_PREF_KEYS:SHARED_PREF_KEYS,LEGACY_MEMORY_PERSIST_NAMESPACE:LEGACY_MEMORY_PERSIST_NAMESPACE,prefsDir:prefsDir,prefsFile:prefsFile,load:load,set:set,get:get,has:has,remove:remove,scanForSecrets:scanForSecrets,assertNoSecrets:assertNoSecrets,extractNonSecretHalf:extractNonSecretHalf,adopt:adopt};
|
|
@@ -1,109 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
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
|
-
* The wire contract and the sanitising helpers for the presence beacon
|
|
10
|
-
* (client/presence.js).
|
|
11
|
-
*
|
|
12
|
-
* These live in their own file for one reason: the widths and the app-name
|
|
13
|
-
* vocabulary below are not this client's invention, they are aegis1's
|
|
14
|
-
* (`_presence_token`, `_presence_app` and `PRESENCE_BEAT_INTERVAL_S` in
|
|
15
|
-
* aegis1/app.py, and the `client_presence` table in aegis1/db.py). A test can
|
|
16
|
-
* assert them without loading the network module, and if the server widens a
|
|
17
|
-
* column the mismatch is visible in one file instead of being spread through
|
|
18
|
-
* the beacon's request path.
|
|
19
|
-
*
|
|
20
|
-
* Kept dependency-free: plain Node, no Electron, no aegis.js — `presence.js`
|
|
21
|
-
* requires this, and the tests require both.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** aegis1: `@app.route("/api/v1/heartbeat", methods=["POST"])`. The v1 path is
|
|
25
|
-
* the client beacon; `/api/heartbeat` is the dashboard's own JS ping and is
|
|
26
|
-
* deliberately NOT used here — it takes no app/install field and writes no
|
|
27
|
-
* presence row. */
|
|
28
|
-
const PRESENCE_PATH = '/api/v1/heartbeat';
|
|
29
|
-
|
|
30
|
-
/** Fallback cadence, in ms, used until the server answers with its own.
|
|
31
|
-
* aegis1's PRESENCE_BEAT_INTERVAL_S is 300 (5 min) as this is written; if the
|
|
32
|
-
* two ever disagree the server's value wins at runtime, so this only bounds
|
|
33
|
-
* the very first beat. */
|
|
34
|
-
const DEFAULT_INTERVAL_MS = 300 * 1000;
|
|
35
|
-
|
|
36
|
-
/** One minute. `/api/heartbeat` and `/api/v1/heartbeat` are both
|
|
37
|
-
* `@login_required`, and that decorator throttles `_track_request()` to
|
|
38
|
-
* 1/min/user — beating faster than this would buy nothing but load. */
|
|
39
|
-
const MIN_INTERVAL_MS = 60 * 1000;
|
|
40
|
-
|
|
41
|
-
/** One hour: the ceiling the server may ask for, and the ceiling the backoff
|
|
42
|
-
* ladder may reach. Past this the panel's "online" window is meaningless. */
|
|
43
|
-
const MAX_INTERVAL_MS = 60 * 60 * 1000;
|
|
44
|
-
|
|
45
|
-
/** Failures slow the beacon down rather than retrying on the same rhythm. */
|
|
46
|
-
const BACKOFF_FACTOR = 2;
|
|
47
|
-
const MAX_BACKOFF_MS = 30 * 60 * 1000;
|
|
48
|
-
|
|
49
|
-
/** A beacon that has not answered in 20 s is a beacon that is not going to:
|
|
50
|
-
* the request body is ~120 bytes. Kept well under MIN_INTERVAL_MS so an
|
|
51
|
-
* in-flight beat can never overlap the next scheduled one. */
|
|
52
|
-
const REQUEST_TIMEOUT_MS = 20 * 1000;
|
|
53
|
-
|
|
54
|
-
/** The app vocabulary, copied EXACTLY from aegis1's `PRESENCE_APPS`
|
|
55
|
-
* (aegis1/app.py, right above `_presence_app`). This is not a suggestion: the
|
|
56
|
-
* server keeps a name only if it is in that tuple and maps everything else to
|
|
57
|
-
* 'other', so a client that sends a name of its own invention is a client the
|
|
58
|
-
* operator cannot identify in the panel — silently, with a 200 and no error
|
|
59
|
-
* anywhere. `test/cli-presence.test.mjs` parses PRESENCE_APPS out of app.py and
|
|
60
|
-
* fails if these two lists drift.
|
|
61
|
-
*
|
|
62
|
-
* The terminal host sends 'cli' rather than a product name for that reason:
|
|
63
|
-
* 'aegiscode' is not in the server's tuple, so it would land as 'other'. */
|
|
64
|
-
const APP_NAMES = Object.freeze(['desktop', 'cli', 'mcp', 'extension', 'web']);
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* One wire-safe token: trimmed, control characters and whitespace stripped,
|
|
68
|
-
* truncated to the width the server's `_presence_token()` will accept. Returns
|
|
69
|
-
* '' for anything that is not a usable string, so a caller can use the empty
|
|
70
|
-
* result as "omit this field" rather than sending `null`.
|
|
71
|
-
*
|
|
72
|
-
* The sanitising is done HERE rather than left to the server on purpose: the
|
|
73
|
-
* server sanitises so a bad field cannot 400 the beacon, but a client that
|
|
74
|
-
* relies on that ships a body it cannot describe. Truncating at the source
|
|
75
|
-
* means what this code sends and what the presence row stores are the same
|
|
76
|
-
* string.
|
|
77
|
-
*/
|
|
78
|
-
function saneToken(value, limit) {
|
|
79
|
-
try {
|
|
80
|
-
if (value == null) return '';
|
|
81
|
-
if (typeof value === 'number' && !Number.isFinite(value)) return '';
|
|
82
|
-
const text = String(value).replace(/[\u0000-\u001f\u007f\s]+/g, '');
|
|
83
|
-
const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 64;
|
|
84
|
-
return text.slice(0, max);
|
|
85
|
-
} catch {
|
|
86
|
-
return '';
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** A non-negative integer queue depth. aegis1 clamps to [0, 100000] itself;
|
|
91
|
-
* clamping here too means the two never disagree about what was sent. */
|
|
92
|
-
function saneCount(value) {
|
|
93
|
-
const n = Number(value);
|
|
94
|
-
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
95
|
-
return Math.min(100000, Math.floor(n));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
module.exports = {
|
|
99
|
-
PRESENCE_PATH,
|
|
100
|
-
DEFAULT_INTERVAL_MS,
|
|
101
|
-
MIN_INTERVAL_MS,
|
|
102
|
-
MAX_INTERVAL_MS,
|
|
103
|
-
BACKOFF_FACTOR,
|
|
104
|
-
MAX_BACKOFF_MS,
|
|
105
|
-
REQUEST_TIMEOUT_MS,
|
|
106
|
-
APP_NAMES,
|
|
107
|
-
saneToken,
|
|
108
|
-
saneCount,
|
|
109
|
-
};
|
|
3
|
+
"use strict";const PRESENCE_PATH="/api/v1/heartbeat",DEFAULT_INTERVAL_MS=3e5,MIN_INTERVAL_MS=6e4,MAX_INTERVAL_MS=36e5,BACKOFF_FACTOR=2,MAX_BACKOFF_MS=18e5,REQUEST_TIMEOUT_MS=2e4,APP_NAMES=Object.freeze(["desktop","cli","mcp","extension","web"]);function saneToken(e,n){try{if(null==e)return"";if("number"==typeof e&&!Number.isFinite(e))return"";const t=String(e).replace(/[\u0000-\u001f\u007f\s]+/g,""),_=Number.isFinite(Number(n))&&Number(n)>0?Math.floor(Number(n)):64;return t.slice(0,_)}catch{return""}}function saneCount(e){const n=Number(e);return!Number.isFinite(n)||n<=0?0:Math.min(1e5,Math.floor(n))}module.exports={PRESENCE_PATH:PRESENCE_PATH,DEFAULT_INTERVAL_MS:3e5,MIN_INTERVAL_MS:6e4,MAX_INTERVAL_MS:36e5,BACKOFF_FACTOR:2,MAX_BACKOFF_MS:18e5,REQUEST_TIMEOUT_MS:2e4,APP_NAMES:APP_NAMES,saneToken:saneToken,saneCount:saneCount};
|