aegis-desktop 0.8.11 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auto.js +423 -0
- package/lib/avatar/packs/core-wardrobe.pack.json +1 -1
- package/lib/avatar/packs/local-voice-core.pack.json +1 -1
- package/lib/avatar/packs/seasonal-winter.pack.json +1 -1
- package/lib/avatar/profile.js +10 -1
- package/lib/fingerprint.js +327 -0
- package/lib/local/autonomous.js +1 -0
- package/lib/local/prompt.js +6 -0
- package/lib/settings.js +154 -1
- package/main.js +316 -17
- package/package.json +2 -2
- package/preload.js +84 -4
- package/renderer/app.js +208 -4
- package/renderer/fingerprint.css +143 -0
- package/renderer/fingerprint.js +583 -0
- package/renderer/index.html +163 -1
- package/vendor/aegis.js +180 -18
- package/vendor/attest.js +439 -0
- package/vendor/authorship-key.js +366 -0
- package/vendor/device-fingerprint.js +85 -3
- package/vendor/fingerprint.js +1061 -0
- package/vendor/presence-constants.js +104 -0
- package/vendor/presence.js +435 -0
- package/vendor/record-vault.js +834 -0
- package/vendor/touch.js +455 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The desktop's φ(α) surface: the same keys and the same records the terminal
|
|
5
|
+
* host mints, driven over IPC instead of argv.
|
|
6
|
+
*
|
|
7
|
+
* `main.js` builds one of these in `createIpcDispatch` and exposes four
|
|
8
|
+
* handlers (`fingerprintStatus`, `fingerprintKeygen`, `fingerprintMint`,
|
|
9
|
+
* `fingerprintVerify` + `fingerprintBackup`), which `preload.js` forwards. The
|
|
10
|
+
* cryptography is NOT here — it is `client/{fingerprint,authorship-key,attest}.js`,
|
|
11
|
+
* the same files the CLI calls, so a record minted in the app verifies in the
|
|
12
|
+
* terminal and vice versa. A second implementation would be the whole bug this
|
|
13
|
+
* arrangement exists to prevent, and `test/fingerprint-interop.test.mjs` is
|
|
14
|
+
* what holds the engine side of that too.
|
|
15
|
+
*
|
|
16
|
+
* ## Publishing (the vault) and the one thing that never goes there
|
|
17
|
+
*
|
|
18
|
+
* A record can be published to the shared vault (`fingerprintPublish` →
|
|
19
|
+
* `client/record-vault.js`, default `gdrive:AEGIS-AGPL`), which writes the
|
|
20
|
+
* *sealed bytes* unchanged plus a `.anchor.json` **link beside the local copy**
|
|
21
|
+
* — so "where is this record published?" is answerable later, from the record.
|
|
22
|
+
* The ownership key is NOT publishable and is not offered: the vault module
|
|
23
|
+
* refuses a key-shaped file by name, by shape and by comparing the bytes against
|
|
24
|
+
* the resolved key, and this service never passes one. The GUI is the host most
|
|
25
|
+
* likely to be asked to "just back the key up to Drive", so the refusal is
|
|
26
|
+
* enforced in the shared module rather than left as a rule in this comment.
|
|
27
|
+
*
|
|
28
|
+
* ## Why it takes `dir` instead of reading `$AEGIS_HOME`
|
|
29
|
+
*
|
|
30
|
+
* The app already owns a data directory (`app.getPath('userData')` in the
|
|
31
|
+
* packaged app, the repo's `.aegiscode` in dev) and every other store the app
|
|
32
|
+
* touches lives in it. A key that resolved to a *different* directory than the
|
|
33
|
+
* one `aegiscode login` writes credentials to would be a second, invisible
|
|
34
|
+
* identity — the exact failure the shared credential store was built to avoid.
|
|
35
|
+
* So the directory is injected, and `AEGIS_HOME` is set from it for the shared
|
|
36
|
+
* modules: one store, both hosts, no ambient guesswork.
|
|
37
|
+
*
|
|
38
|
+
* ## Errors are values, not throws
|
|
39
|
+
*
|
|
40
|
+
* Every method returns `{ ok, … }` or `{ ok: false, error }`. An IPC handler
|
|
41
|
+
* that rejects gives the renderer an opaque `Error: Error invoking remote
|
|
42
|
+
* method` string with the real message buried in main-process logs; a returned
|
|
43
|
+
* error is a message the UI can show verbatim, which matters most for the one
|
|
44
|
+
* error a user is likely to hit ("this key file is readable by other users").
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const fs = require('node:fs');
|
|
48
|
+
const path = require('node:path');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a shared client module by existence: repo checkout first, then the
|
|
52
|
+
* packaged app's flattened `vendor/` (electron-builder cannot reach outside the
|
|
53
|
+
* app directory — see scripts/predist.mjs, which must stage all four).
|
|
54
|
+
*/
|
|
55
|
+
function resolveShared(name) {
|
|
56
|
+
const candidates = [
|
|
57
|
+
path.join(__dirname, '..', '..', 'client', name), // dev / CI: repo root
|
|
58
|
+
path.join(__dirname, '..', 'vendor', name), // packaged: desktop/vendor
|
|
59
|
+
];
|
|
60
|
+
for (const candidate of candidates) {
|
|
61
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
62
|
+
}
|
|
63
|
+
throw new Error(
|
|
64
|
+
`aegis-desktop: cannot find client/${name} — expected it beside desktop/ in the repo ` +
|
|
65
|
+
'or under desktop/vendor/ in a build. Run `npm run predist` in desktop/.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function load() {
|
|
70
|
+
const fp = require(resolveShared('fingerprint.js'));
|
|
71
|
+
return {
|
|
72
|
+
fp,
|
|
73
|
+
authorship: require(resolveShared('authorship-key.js')),
|
|
74
|
+
attest: require(resolveShared('attest.js')),
|
|
75
|
+
vault: require(resolveShared('record-vault.js')),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function slug(value, max = 80) {
|
|
80
|
+
return (
|
|
81
|
+
String(value || 'session')
|
|
82
|
+
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
|
83
|
+
.replace(/^[._-]+/, '')
|
|
84
|
+
.slice(0, max) || 'session'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {object} o
|
|
90
|
+
* @param {string} o.dir the app's data directory (userData in a package)
|
|
91
|
+
* @param {object} [o.env] process env, injectable for tests
|
|
92
|
+
*/
|
|
93
|
+
function createFingerprintService({ dir, env } = {}) {
|
|
94
|
+
if (!dir) throw new Error('aegis-desktop: createFingerprintService needs a data dir');
|
|
95
|
+
// One store: the shared modules read AEGIS_HOME and nothing else.
|
|
96
|
+
const scope = { ...(env || process.env), AEGIS_HOME: (env || process.env).AEGIS_HOME || dir };
|
|
97
|
+
|
|
98
|
+
const recordPath = (record, session) =>
|
|
99
|
+
path.join(dir, 'fingerprints', `${slug(session)}.${String(record.createdAt).replace(/[:.]/g, '-')}.json`);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
/** Which key is in use, and where it came from. Never returns the key. */
|
|
103
|
+
status() {
|
|
104
|
+
try {
|
|
105
|
+
const { fp, authorship, vault } = load();
|
|
106
|
+
const key = authorship.resolveKey({ argv: [], env: scope });
|
|
107
|
+
const history = path.join(authorship.dataDir(scope), 'history.jsonl');
|
|
108
|
+
const target = vault.vaultTarget(scope);
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
configured: Boolean(key.secret),
|
|
112
|
+
keySource: key.source,
|
|
113
|
+
keyPath: key.path,
|
|
114
|
+
keyFingerprint: key.secret ? fp.keyFingerprint(key.secret) : null,
|
|
115
|
+
publicKey: key.public,
|
|
116
|
+
defaultKeyPath: authorship.defaultKeyPath(scope),
|
|
117
|
+
historyPath: history,
|
|
118
|
+
historyPresent: fs.existsSync(history),
|
|
119
|
+
dataDir: authorship.dataDir(scope),
|
|
120
|
+
// Where a publish goes, so the panel can say it before the user asks
|
|
121
|
+
// for one — and so a misconfigured root is visible, not discovered by
|
|
122
|
+
// a record landing in the wrong folder.
|
|
123
|
+
vault: { remote: target.remote, root: target.root, recordsDir: `${target.root}/${vault.RECORDS_DIR}` },
|
|
124
|
+
};
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** Mint a private key. `force` replaces one, which orphans its records. */
|
|
131
|
+
keygen({ out = null, force = false } = {}) {
|
|
132
|
+
try {
|
|
133
|
+
const { authorship } = load();
|
|
134
|
+
const argv = [];
|
|
135
|
+
if (out) argv.push('--out', out);
|
|
136
|
+
if (force) argv.push('--force');
|
|
137
|
+
const r = authorship.keygen({ argv, env: scope });
|
|
138
|
+
return { ok: true, ...r };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Seal a session as a record.
|
|
146
|
+
*
|
|
147
|
+
* `turns` may be passed in (the renderer's own view of the session) or left
|
|
148
|
+
* out, in which case the shared adapter reads the session log — same code
|
|
149
|
+
* path as the CLI, so a GUI record and a terminal record of the same
|
|
150
|
+
* session carry the same behaviour digest — and the same AI digest, from the
|
|
151
|
+
* same turns.
|
|
152
|
+
*/
|
|
153
|
+
mint({ history = null, session = null, turns = null, label = null, account = null,
|
|
154
|
+
model = null, tools = null, codeRoots = null, codeDirty = null, out = null,
|
|
155
|
+
createdAt = null, publish = false, aiSecret = undefined, aiModel = null } = {}) {
|
|
156
|
+
try {
|
|
157
|
+
const { attest, authorship } = load();
|
|
158
|
+
const roots = Array.isArray(codeRoots) ? codeRoots.filter(Boolean) : [];
|
|
159
|
+
const r = attest.mintSession({
|
|
160
|
+
argv: [],
|
|
161
|
+
env: scope,
|
|
162
|
+
history: history || path.join(authorship.dataDir(scope), 'history.jsonl'),
|
|
163
|
+
session: session || undefined,
|
|
164
|
+
turns: turns || undefined,
|
|
165
|
+
label: label || undefined,
|
|
166
|
+
account: account || undefined,
|
|
167
|
+
model: model || undefined,
|
|
168
|
+
tools: Array.isArray(tools) ? tools : undefined,
|
|
169
|
+
code: roots.length ? attest.collectCode({ roots }) : [],
|
|
170
|
+
codeDirty: typeof codeDirty === 'boolean' ? codeDirty : null,
|
|
171
|
+
// The AI axis. `undefined` walks the shared rungs (`--ai-secret` never
|
|
172
|
+
// applies here; `$AEGIS_AI_SECRET` in the app's scope does), and an
|
|
173
|
+
// explicit null means "no credential — mint the axis as unobserved".
|
|
174
|
+
// The authorship key is never borrowed for this: it says who sealed the
|
|
175
|
+
// record, not which mind answered.
|
|
176
|
+
aiSecret,
|
|
177
|
+
aiModel: aiModel === null ? undefined : aiModel,
|
|
178
|
+
// Pinning the time is how the CLI and the app mint the *same* record
|
|
179
|
+
// for the same session (see client/attest.js). Left null, the record
|
|
180
|
+
// is stamped now, which is what an ordinary seal wants.
|
|
181
|
+
createdAt: createdAt || undefined,
|
|
182
|
+
});
|
|
183
|
+
const file = path.resolve(out || recordPath(r.record, session || 'last'));
|
|
184
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
185
|
+
fs.writeFileSync(file, JSON.stringify(r.record, null, 2) + '\n');
|
|
186
|
+
|
|
187
|
+
// Sealing and travelling are separate acts: `publish` is opt-in, and a
|
|
188
|
+
// failed publish NEVER unseals the record — the local file is already
|
|
189
|
+
// written and the error is reported beside it, because the seal is the
|
|
190
|
+
// evidence and the copy is the convenience.
|
|
191
|
+
let vault = null;
|
|
192
|
+
let vaultError = null;
|
|
193
|
+
if (publish) {
|
|
194
|
+
try {
|
|
195
|
+
const key = authorship.resolveKey({ argv: [], env: scope });
|
|
196
|
+
vault = load().vault.publish({ file, argv: [], env: scope, secret: key.secret });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
vaultError = (e && e.message) || String(e);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
record: r.record,
|
|
204
|
+
out: file,
|
|
205
|
+
keySource: r.keySource,
|
|
206
|
+
public: r.public,
|
|
207
|
+
vault,
|
|
208
|
+
vaultError,
|
|
209
|
+
};
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Publish a sealed record to the vault, and link the local copy to it.
|
|
217
|
+
*
|
|
218
|
+
* `record` is accepted as well as `file` so the panel can publish what it
|
|
219
|
+
* just minted without a round trip through the filesystem — but the
|
|
220
|
+
* preferred path is the file, because the sha256 in the anchor is then about
|
|
221
|
+
* the bytes on disk rather than a re-serialisation of them.
|
|
222
|
+
*/
|
|
223
|
+
publish({ file = null, record = null, dryRun = false } = {}) {
|
|
224
|
+
try {
|
|
225
|
+
const { authorship, vault } = load();
|
|
226
|
+
const key = authorship.resolveKey({ argv: [], env: scope });
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
...vault.publish({ file, record, argv: [], env: scope, secret: key.secret, dryRun }),
|
|
230
|
+
};
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Is the published copy still the record that was sealed?
|
|
238
|
+
*
|
|
239
|
+
* Reports the three claims separately (`present`, `unchanged`, `attested`),
|
|
240
|
+
* because "the file is gone" and "the file was replaced" and "the key that
|
|
241
|
+
* sealed it has moved on" are three different situations and a single
|
|
242
|
+
* boolean would flatten them into one that hides the interesting case.
|
|
243
|
+
*/
|
|
244
|
+
anchor({ file = null, record = null, anchor = null } = {}) {
|
|
245
|
+
try {
|
|
246
|
+
const { authorship, vault } = load();
|
|
247
|
+
if (!file && !record && !anchor) return { ok: false, error: 'anchor needs a record or an anchor' };
|
|
248
|
+
const key = authorship.resolveKey({ argv: [], env: scope });
|
|
249
|
+
return { ok: true, ...vault.checkAnchor({ file, record, anchor, argv: [], env: scope, key }) };
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** What is in the vault. */
|
|
256
|
+
vaultList() {
|
|
257
|
+
try {
|
|
258
|
+
const { vault } = load();
|
|
259
|
+
return { ok: true, ...vault.list({ argv: [], env: scope }) };
|
|
260
|
+
} catch (e) {
|
|
261
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Inspect the vault for key material — including the `keys/` copy that was
|
|
267
|
+
* already there before publishing was restricted. A finding is a fact about
|
|
268
|
+
* the vault, not about this app, so it is returned rather than acted on.
|
|
269
|
+
*/
|
|
270
|
+
vaultAudit() {
|
|
271
|
+
try {
|
|
272
|
+
const { authorship, vault } = load();
|
|
273
|
+
const key = authorship.resolveKey({ argv: [], env: scope });
|
|
274
|
+
return { ok: true, ...vault.audit({ argv: [], env: scope, secret: key.secret || null }) };
|
|
275
|
+
} catch (e) {
|
|
276
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Recompute a record's attestation. Accepts a path or the record itself, so
|
|
282
|
+
* the renderer can verify what it just minted without a round trip through
|
|
283
|
+
* the filesystem.
|
|
284
|
+
*/
|
|
285
|
+
verify({ file = null, record = null } = {}) {
|
|
286
|
+
try {
|
|
287
|
+
const { fp, authorship } = load();
|
|
288
|
+
let body = record;
|
|
289
|
+
if (!body) {
|
|
290
|
+
if (!file) return { ok: false, error: 'verify needs a record file or the record itself' };
|
|
291
|
+
body = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
292
|
+
}
|
|
293
|
+
const key = authorship.resolveKey({ argv: [], env: scope });
|
|
294
|
+
if (!key.secret) {
|
|
295
|
+
return { ok: false, error: 'no authorship key — run keygen first', reason: 'no key' };
|
|
296
|
+
}
|
|
297
|
+
const v = fp.verify(body, key.secret);
|
|
298
|
+
return {
|
|
299
|
+
ok: v.ok,
|
|
300
|
+
reason: v.reason,
|
|
301
|
+
id: v.id,
|
|
302
|
+
credentialMatch: v.credentialMatch,
|
|
303
|
+
keySource: key.source,
|
|
304
|
+
recordedKeyFingerprint: body.keyFingerprint || null,
|
|
305
|
+
};
|
|
306
|
+
} catch (e) {
|
|
307
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/** Copy the key out. Refuses a destination inside a non-ignored git tree. */
|
|
312
|
+
backup({ to = null, name = null, allowGit = false } = {}) {
|
|
313
|
+
try {
|
|
314
|
+
if (!to) return { ok: false, error: 'backup needs a destination directory' };
|
|
315
|
+
const { authorship } = load();
|
|
316
|
+
const argv = ['--to', to];
|
|
317
|
+
if (name) argv.push('--name', name);
|
|
318
|
+
if (allowGit) argv.push('--allow-git');
|
|
319
|
+
return { ok: true, ...authorship.backup({ argv, env: scope }) };
|
|
320
|
+
} catch (e) {
|
|
321
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = { createFingerprintService, resolveShared, slug };
|
package/lib/local/autonomous.js
CHANGED
|
@@ -322,6 +322,7 @@ function autonomousDirective(rounds = DEFAULT_ROUNDS) {
|
|
|
322
322
|
'Work the task end to end:',
|
|
323
323
|
'1. Plan in one line, then start acting in the same turn — never end a turn on a plan.',
|
|
324
324
|
'2. Read only what you need to make the change (no fishing through the repo).',
|
|
325
|
+
'2b. If this demand has been made before, establish whether it was met: check recalled memory, the queue file, PLAN.md, and git history for the earlier attempt. When it was fixed and the problem is back, state how it was fixed then and why it regressed, and fix that cause — a blind re-fix is a regression, not a resolution.',
|
|
325
326
|
'3. Make the change with Write/Edit/Bash, then VERIFY it: re-read the result and run the relevant test/command.',
|
|
326
327
|
'4. If verification fails, fix it and verify again — loop until it passes or you are genuinely blocked.',
|
|
327
328
|
'5. Do not ask for permission, do not hand the work back, do not stop at "let me check…".',
|
package/lib/local/prompt.js
CHANGED
|
@@ -31,6 +31,12 @@ const MAIN_CHAT_PROMPT =
|
|
|
31
31
|
`- Use tools silently. A one-line reason is enough; do not narrate your plan as a story. ` +
|
|
32
32
|
`- Never claim what a tool found or what a command returned before the tool actually runs. ` +
|
|
33
33
|
` Report only the results you really received. ` +
|
|
34
|
+
`- Before you start a fix or feature the user asks for, check whether they have asked for ` +
|
|
35
|
+
` it before and whether it was already delivered: look in recalled memory, ` +
|
|
36
|
+
` ~/.aegiscode/queue.jsonl, PLAN.md/notes, and \`git log\` for the same demand. If it was ` +
|
|
37
|
+
` resolved before and the problem is back, report how it was resolved (the commit, file, or ` +
|
|
38
|
+
` mechanism that fixed it) and why it regressed, then fix the cause — never re-fix blind as ` +
|
|
39
|
+
` if the request were new. If it was never met, say so plainly before starting. ` +
|
|
34
40
|
`- Match the response to the request. A greeting, a question about something you already ` +
|
|
35
41
|
` know, or a request for an opinion is answered in plain words with NO tools. Only reach ` +
|
|
36
42
|
` for a tool when the answer genuinely depends on something in this repo or on this ` +
|
package/lib/settings.js
CHANGED
|
@@ -92,7 +92,59 @@ const AVATAR_NAMESPACE = '__avatar';
|
|
|
92
92
|
* main.js telemetryEnabled() through getTelemetryOptIn(). */
|
|
93
93
|
const TELEMETRY_NAMESPACE = '__telemetry';
|
|
94
94
|
|
|
95
|
-
/**
|
|
95
|
+
/** Reserved namespace for the auto-drain opt-in:
|
|
96
|
+
* `{ enabled, intervalMs }` — app-level, not a provider.
|
|
97
|
+
*
|
|
98
|
+
* DEFAULT OFF, and unlike every preference above that default is the design
|
|
99
|
+
* rather than a convention: this is the one setting in the app that causes a
|
|
100
|
+
* full tool loop to run on a real model in the user's checkout with nobody
|
|
101
|
+
* watching. desktop/main.js and desktop/preload.js both used to state the
|
|
102
|
+
* absolute rule — "there is no interval, no drain at startup, and no
|
|
103
|
+
* drain-on-idle anywhere" — and that rule is still what an install that never
|
|
104
|
+
* touches this namespace gets. What changed (user request, phase A of the
|
|
105
|
+
* automation work) is that the rule became a DEFAULT instead of an absolute:
|
|
106
|
+
* an explicit `enabled: true` here is the only thing that arms a timer, the
|
|
107
|
+
* toggle that writes it is in the queue card, and `lib/auto.js` refuses to
|
|
108
|
+
* run a tick when the queue is empty or another host already holds the drain
|
|
109
|
+
* lock. Nothing secret lives here, so no encryption — same shape as
|
|
110
|
+
* `__confirmMode`/`__memoryPersist` above. */
|
|
111
|
+
const AUTO_DRAIN_NAMESPACE = '__autoDrain';
|
|
112
|
+
|
|
113
|
+
/** Reserved namespace for the auto-mint (seal-on-quiet) opt-in:
|
|
114
|
+
* `{ enabled, publish }` — app-level, not a provider.
|
|
115
|
+
*
|
|
116
|
+
* DEFAULT OFF for the same reason as auto-drain, and `publish` is a second,
|
|
117
|
+
* independent opt-in: sealing writes a local record, whereas publishing sends
|
|
118
|
+
* those bytes to the vault over `rclone` and writes an anchor beside them. An
|
|
119
|
+
* automatic network copy is never implied by an automatic seal.
|
|
120
|
+
*
|
|
121
|
+
* The AI half of the record resolves through the shared rungs in
|
|
122
|
+
* client/attest.js (`$AEGIS_AI_SECRET`/`$AEGIS_AI_MODEL` in the app scope) —
|
|
123
|
+
* this namespace deliberately does NOT hold a credential, because a settings
|
|
124
|
+
* file that can carry a provider key is a second place to leak one. */
|
|
125
|
+
const AUTO_MINT_NAMESPACE = '__autoMint';
|
|
126
|
+
|
|
127
|
+
/** How often an armed auto-drain looks at the queue. One minute is the
|
|
128
|
+
* compromise the two failure modes set: a shorter interval makes the desktop
|
|
129
|
+
* a busy-waiter over a file several hosts write (each tick stats the queue and
|
|
130
|
+
* the lock), and a longer one means a task queued in the card sits visibly
|
|
131
|
+
* idle. The worker does the rest of the waiting itself. */
|
|
132
|
+
const AUTO_DRAIN_INTERVAL_DEFAULT_MS = 60_000;
|
|
133
|
+
|
|
134
|
+
/** Floor for `intervalMs`. Below this the timer is a poll loop, not a drain
|
|
135
|
+
* cadence: nothing about a queue whose tasks take minutes to run changes
|
|
136
|
+
* fifteen times a second, and the tick does file I/O. */
|
|
137
|
+
const AUTO_DRAIN_INTERVAL_MIN_MS = 15_000;
|
|
138
|
+
|
|
139
|
+
/** Ceiling for `intervalMs` — half an hour. A hand-edited settings file asking
|
|
140
|
+
* for a day is a typo, not a preference. */
|
|
141
|
+
const AUTO_DRAIN_INTERVAL_MAX_MS = 30 * 60_000;
|
|
142
|
+
|
|
143
|
+
/** Reserved namespaces: the provider-config surface must never see or mutate
|
|
144
|
+
* any of these. The two automation switches are here for the SAME reason the
|
|
145
|
+
* others are — `set()`/`remove()`/`list()` operate on provider rows, and a
|
|
146
|
+
* preference parked in that namespace would be listed as a provider and
|
|
147
|
+
* deletable with "Remove". */
|
|
96
148
|
const RESERVED_NAMESPACES = Object.freeze([
|
|
97
149
|
AEGIS_KEY_NAMESPACE,
|
|
98
150
|
LEGACY_AEGIS_NAMESPACE,
|
|
@@ -101,6 +153,8 @@ const RESERVED_NAMESPACES = Object.freeze([
|
|
|
101
153
|
MEMORY_PERSIST_NAMESPACE,
|
|
102
154
|
AVATAR_NAMESPACE,
|
|
103
155
|
TELEMETRY_NAMESPACE,
|
|
156
|
+
AUTO_DRAIN_NAMESPACE,
|
|
157
|
+
AUTO_MINT_NAMESPACE,
|
|
104
158
|
]);
|
|
105
159
|
|
|
106
160
|
/** Motion modes the renderer understands (`system` = follow the OS). */
|
|
@@ -451,6 +505,93 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
451
505
|
return avatarState();
|
|
452
506
|
}
|
|
453
507
|
|
|
508
|
+
// --- Auto-drain / auto-mint: reserved namespaces, DEFAULT OFF ------------
|
|
509
|
+
// The two opt-in automations (lib/auto.js). Both live in their own top-level
|
|
510
|
+
// namespace, both are booted OFF, and both keep the "off" and "never set"
|
|
511
|
+
// cases distinguishable through a `source` field — the same shape
|
|
512
|
+
// memoryPersistState()/avatarState() use above, for the same reason: a UI
|
|
513
|
+
// that cannot tell "you turned it off" from "this build never had it" shows
|
|
514
|
+
// the wrong sentence after an upgrade.
|
|
515
|
+
//
|
|
516
|
+
// `intervalMs` is clamped rather than trusted: it comes back through IPC from
|
|
517
|
+
// a renderer and off disk from a hand-edited file, and a timer is the one
|
|
518
|
+
// place where a hostile or fat-fingered number costs real CPU.
|
|
519
|
+
|
|
520
|
+
/** A usable tick interval, or the default. Out-of-range reads as unset. */
|
|
521
|
+
function clampAutoDrainInterval(value) {
|
|
522
|
+
const n = Number(value);
|
|
523
|
+
if (!Number.isFinite(n)) return AUTO_DRAIN_INTERVAL_DEFAULT_MS;
|
|
524
|
+
const ms = Math.round(n);
|
|
525
|
+
if (ms < AUTO_DRAIN_INTERVAL_MIN_MS || ms > AUTO_DRAIN_INTERVAL_MAX_MS) {
|
|
526
|
+
return AUTO_DRAIN_INTERVAL_DEFAULT_MS;
|
|
527
|
+
}
|
|
528
|
+
return ms;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function getAutoDrain() {
|
|
532
|
+
const cfg = load()[AUTO_DRAIN_NAMESPACE] || {};
|
|
533
|
+
return {
|
|
534
|
+
// Unset === false. This is the opposite of confirm mode/memory persist,
|
|
535
|
+
// and it is the whole point of the feature: nothing drains itself until
|
|
536
|
+
// someone says so in as many words.
|
|
537
|
+
enabled: cfg.enabled === true,
|
|
538
|
+
intervalMs: clampAutoDrainInterval(cfg.intervalMs),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function autoDrainState() {
|
|
543
|
+
const cfg = load()[AUTO_DRAIN_NAMESPACE] || {};
|
|
544
|
+
return {
|
|
545
|
+
...getAutoDrain(),
|
|
546
|
+
source: cfg.enabled === undefined ? 'default' : 'config',
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function setAutoDrain(patch = {}) {
|
|
551
|
+
const current = getAutoDrain();
|
|
552
|
+
const p = patch && typeof patch === 'object' ? patch : {};
|
|
553
|
+
const data = load();
|
|
554
|
+
data[AUTO_DRAIN_NAMESPACE] = {
|
|
555
|
+
enabled: p.enabled === undefined ? current.enabled : Boolean(p.enabled),
|
|
556
|
+
intervalMs:
|
|
557
|
+
p.intervalMs === undefined
|
|
558
|
+
? current.intervalMs
|
|
559
|
+
: clampAutoDrainInterval(p.intervalMs),
|
|
560
|
+
};
|
|
561
|
+
save(data);
|
|
562
|
+
return autoDrainState();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function getAutoMint() {
|
|
566
|
+
const cfg = load()[AUTO_MINT_NAMESPACE] || {};
|
|
567
|
+
return {
|
|
568
|
+
enabled: cfg.enabled === true,
|
|
569
|
+
// Independent of `enabled` on purpose: an automatic seal must not drag
|
|
570
|
+
// an automatic upload along with it.
|
|
571
|
+
publish: cfg.publish === true,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function autoMintState() {
|
|
576
|
+
const cfg = load()[AUTO_MINT_NAMESPACE] || {};
|
|
577
|
+
return {
|
|
578
|
+
...getAutoMint(),
|
|
579
|
+
source: cfg.enabled === undefined ? 'default' : 'config',
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function setAutoMint(patch = {}) {
|
|
584
|
+
const current = getAutoMint();
|
|
585
|
+
const p = patch && typeof patch === 'object' ? patch : {};
|
|
586
|
+
const data = load();
|
|
587
|
+
data[AUTO_MINT_NAMESPACE] = {
|
|
588
|
+
enabled: p.enabled === undefined ? current.enabled : Boolean(p.enabled),
|
|
589
|
+
publish: p.publish === undefined ? current.publish : Boolean(p.publish),
|
|
590
|
+
};
|
|
591
|
+
save(data);
|
|
592
|
+
return autoMintState();
|
|
593
|
+
}
|
|
594
|
+
|
|
454
595
|
return {
|
|
455
596
|
file,
|
|
456
597
|
get,
|
|
@@ -476,6 +617,13 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
476
617
|
getAvatar,
|
|
477
618
|
avatarState,
|
|
478
619
|
setAvatar,
|
|
620
|
+
getAutoDrain,
|
|
621
|
+
autoDrainState,
|
|
622
|
+
setAutoDrain,
|
|
623
|
+
getAutoMint,
|
|
624
|
+
autoMintState,
|
|
625
|
+
setAutoMint,
|
|
626
|
+
clampAutoDrainInterval,
|
|
479
627
|
};
|
|
480
628
|
}
|
|
481
629
|
|
|
@@ -488,6 +636,11 @@ module.exports = {
|
|
|
488
636
|
MEMORY_PERSIST_NAMESPACE,
|
|
489
637
|
AVATAR_NAMESPACE,
|
|
490
638
|
TELEMETRY_NAMESPACE,
|
|
639
|
+
AUTO_DRAIN_NAMESPACE,
|
|
640
|
+
AUTO_MINT_NAMESPACE,
|
|
641
|
+
AUTO_DRAIN_INTERVAL_DEFAULT_MS,
|
|
642
|
+
AUTO_DRAIN_INTERVAL_MIN_MS,
|
|
643
|
+
AUTO_DRAIN_INTERVAL_MAX_MS,
|
|
491
644
|
AVATAR_MOTION_MODES,
|
|
492
645
|
DEFAULT_QUICK_LAUNCHER_SHORTCUT,
|
|
493
646
|
RESERVED_NAMESPACES,
|