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.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
@@ -1,643 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * identity.js — whose memory it is.
10
- *
11
- * Memory is the value; value is meaningless
12
- * unattributed. This module answers exactly one question — *which holder is at
13
- * the keyboard* — from signals the caller already has, and it answers with a
14
- * **proposal plus provenance**, never with a merge.
15
- *
16
- * The precedence (identity plan §2) is deliberate and ordered by how much the
17
- * signal actually proves:
18
- *
19
- * explicit `AEGIS_AVATAR_HOLDER` / persona `identity.holderId`.
20
- * Deliberate beats inferred — CI and power users need it.
21
- * account a cloud account id. Survives key rotation.
22
- * keyFingerprint `sha256(key).slice(0, FINGERPRINT_LENGTH)`, computed in
23
- * main. The only signal a fresh install has.
24
- * local a minted `h_<12 hex>`: single-user offline machines.
25
- *
26
- * Four rules, and each one is an assertion in `desktop/test/avatar-identity
27
- * .test.mjs` rather than a sentence in a doc:
28
- *
29
- * 1. **A fingerprint is not an identity.** It says "same credential", not
30
- * "same person" — two holders on one box can legitimately share a key. So a
31
- * fingerprint match that disagrees with the registry's active holder
32
- * returns `conflict: { a, b, reason }` and *both rows keep existing*. Nothing
33
- * here ever moves data between holders.
34
- * 2. **No key material, anywhere.** `fingerprint()` is the only thing that sees
35
- * a key, it is one-way and truncated, and `resolve()` accepts a fingerprint
36
- * *by shape* (12 lowercase hex) or not at all: a raw key, or an untruncated
37
- * 64-char digest, is rejected and warned about rather than trusted. Nothing
38
- * returned by this module can carry a long high-entropy hex run, so a caller
39
- * cannot accidentally log one.
40
- * 3. **The OS user name is a hint, never an id.** `neo` on two machines is two
41
- * relationships and `neo` on a shared box is not a person, so `osUser` can
42
- * only ever influence `displayName`.
43
- * 4. **Resolution is pure and never writes.** `holders.json` is validated,
44
- * repaired and copied; the input registry is never mutated, and the fields
45
- * `proposal`/`conflict`/`needsMint` exist precisely so main writes the file
46
- * with the user's confirmation instead of this module guessing.
47
- *
48
- * Pure: no fs, no Electron, no crypto import — the caller supplies `hash` so
49
- * this file is testable in plain Node exactly like `lib/sync/*`.
50
- */
51
-
52
- const persona = require('./persona.js');
53
-
54
- /** Bump when the registry record shape changes; `migrate` walks it forward. */
55
- const SCHEMA_VERSION = 1;
56
-
57
- /**
58
- * `sha256(apiKey).slice(0, 12)` (identity plan §2). Short on purpose: it is an
59
- * index for a registry row, not an authenticator.
60
- */
61
- const FINGERPRINT_LENGTH = 12;
62
-
63
- /** A registry is an index of at most this many holders. */
64
- const MAX_HOLDERS = 32;
65
- const MAX_DISPLAY_NAME = 40;
66
- const MAX_ACCOUNT_ID = 64;
67
-
68
- /** Minted ids: `h_` + 12 lowercase hex, matching the plan's examples. */
69
- const HOLDER_ID_RE = /^h_[0-9a-f]{12}$/;
70
- /**
71
- * Ids accepted *from the outside* (env, persona, registry file): wider than we
72
- * mint, still narrow. Capped at 16 hex so no field this module returns can
73
- * contain a 20+ character hex run (rule 2) even when the caller passes one.
74
- */
75
- const HOLDER_ID_SHAPE = /^h_[0-9a-f]{8,16}$/;
76
- const FINGERPRINT_RE = /^[0-9a-f]{12}$/;
77
- /** Account ids are opaque server-side strings; this is a shape check only. */
78
- const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{3,63}$/;
79
- /** Anything that looks like key material rather than a name or an id. */
80
- const KEY_SHAPE = /^(?:sk|pk|gsk|xox[baprs]|ghp|glpat|aegis)[-_][A-Za-z0-9_-]{6,}/i;
81
- const LONG_HEX = /[0-9a-f]{20,}/i;
82
- const BEARER = /^Bearer\s+\S+/i;
83
-
84
- /** The neutral fallback. Never the OS user name, never a guess. */
85
- const DEFAULT_DISPLAY_NAME = 'You';
86
-
87
- // ---------------------------------------------------------------------------
88
- // hashing helpers — the caller owns the hash
89
- // ---------------------------------------------------------------------------
90
-
91
- /** Accept a hex string or a byte array (Buffer/Uint8Array) and return hex. */
92
- function toHex(value) {
93
- if (typeof value === 'string') {
94
- const flat = value.trim().toLowerCase().replace(/\s+/g, '');
95
- return /^[0-9a-f]+$/.test(flat) ? flat : null;
96
- }
97
- if (value instanceof Uint8Array || Array.isArray(value)) {
98
- let out = '';
99
- for (const byte of value) {
100
- const b = Number(byte);
101
- if (!Number.isInteger(b) || b < 0 || b > 255) return null;
102
- out += b.toString(16).padStart(2, '0');
103
- }
104
- return out || null;
105
- }
106
- return null;
107
- }
108
-
109
- /**
110
- * One-way, truncated digest of a credential. **The only function in the tree
111
- * that sees a raw key.**
112
- *
113
- * @param {(input: string) => string|Uint8Array} hash caller's digest (e.g.
114
- * `(s) => crypto.createHash('sha256').update(s).digest('hex')`)
115
- * @param {string} key the credential
116
- * @returns {string|null} 12 lowercase hex, or `null` for anything unusable —
117
- * `null` rather than a throw because a missing key is the fresh-install case,
118
- * not an error.
119
- */
120
- function fingerprint(hash, key) {
121
- if (typeof hash !== 'function') return null;
122
- if (typeof key !== 'string') return null;
123
- const trimmed = key.trim();
124
- if (!trimmed) return null;
125
- let digest;
126
- try {
127
- digest = hash(trimmed);
128
- } catch {
129
- return null;
130
- }
131
- const hex = toHex(digest);
132
- if (!hex || hex.length < FINGERPRINT_LENGTH) return null;
133
- return hex.slice(0, FINGERPRINT_LENGTH);
134
- }
135
-
136
- /** Shape check only — this says "a fingerprint could look like this". */
137
- function isFingerprint(value) {
138
- return typeof value === 'string' && FINGERPRINT_RE.test(value.trim().toLowerCase());
139
- }
140
-
141
- /**
142
- * Does this look like a secret rather than an identifier? Used to refuse an
143
- * account id, a display hint, or a fingerprint that is really a key.
144
- */
145
- function looksLikeRawKey(value) {
146
- if (typeof value !== 'string') return false;
147
- const flat = value.trim();
148
- if (!flat) return false;
149
- return KEY_SHAPE.test(flat) || BEARER.test(flat) || LONG_HEX.test(flat);
150
- }
151
-
152
- /**
153
- * Deterministic local id minting: `h_` + 12 hex of `hash(seed)`.
154
- *
155
- * Deterministic on purpose — the same machine seed produces the same id on a
156
- * reinstall, which is what keeps "the ledger survives a reinstall"
157
- * true for an install that never had a cloud key.
158
- *
159
- * @returns {string|null} `null` when there is nothing trustworthy to mint from;
160
- * the caller then falls back to random bytes.
161
- */
162
- function mintHolderId(hash, seed) {
163
- if (typeof hash !== 'function') return null;
164
- if (typeof seed !== 'string' || !seed.trim()) return null;
165
- let digest;
166
- try {
167
- digest = hash(seed.trim());
168
- } catch {
169
- return null;
170
- }
171
- const hex = toHex(digest);
172
- if (!hex || hex.length < FINGERPRINT_LENGTH) return null;
173
- const id = `h_${hex.slice(0, FINGERPRINT_LENGTH)}`;
174
- return HOLDER_ID_RE.test(id) ? id : null;
175
- }
176
-
177
- // ---------------------------------------------------------------------------
178
- // the registry — `holders.json`, an index and not the data
179
- // ---------------------------------------------------------------------------
180
-
181
- /** A display name is a single line of UI text, capped, and never a digest. */
182
- function cleanDisplay(value, fallback = DEFAULT_DISPLAY_NAME) {
183
- if (typeof value !== 'string') return fallback;
184
- // A hint that looks like a credential is not shown as one.
185
- if (looksLikeRawKey(value)) return fallback;
186
- const flat = persona.cleanText(value, MAX_DISPLAY_NAME);
187
- return flat || fallback;
188
- }
189
-
190
- function numOrNull(value) {
191
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
192
- }
193
-
194
- /**
195
- * A short label that is safe to put in a warning: a value we could not accept is
196
- * never echoed back, because the caller may have handed us a credential in the
197
- * wrong slot (rule 2). `warnings` travels into main's log, so echoing it would
198
- * be the leak this module exists to prevent.
199
- */
200
- function safeLabel(value) {
201
- if (typeof value !== 'string' || !value.trim()) return '(empty)';
202
- const flat = value.trim();
203
- if (flat.length > 24 || looksLikeRawKey(flat)) return '(withheld)';
204
- return flat;
205
- }
206
-
207
- /** A valid, mappable holder id from an outside source, or `null`. */
208
- function holderIdShape(value) {
209
- if (typeof value !== 'string') return null;
210
- const id = value.trim().toLowerCase();
211
- return HOLDER_ID_SHAPE.test(id) ? id : null;
212
- }
213
-
214
- function emptyRegistry() {
215
- return { schema: SCHEMA_VERSION, active: null, holders: [] };
216
- }
217
-
218
- /**
219
- * Walk an old record forward. With one schema version this is trivial, but the
220
- * mechanism exists now so that adding v2 is a table entry rather than a
221
- * discovery that shipped registries cannot be read.
222
- */
223
- const MIGRATIONS = Object.freeze({
224
- // 0 → 1: pre-schema registries are partial records; normalisation fills them.
225
- 0: (raw) => Object.assign({}, raw),
226
- });
227
-
228
- function migrate(raw) {
229
- if (!raw || typeof raw !== 'object') return { registry: emptyRegistry(), migrated: false, fromSchema: 0 };
230
- let current = raw;
231
- let version = Number.isInteger(current.schema) ? current.schema : 0;
232
- let migrated = false;
233
- while (version < SCHEMA_VERSION) {
234
- const step = MIGRATIONS[version];
235
- current = step ? step(current) : current;
236
- version += 1;
237
- migrated = true;
238
- }
239
- return { registry: current, migrated, fromSchema: Number.isInteger(raw.schema) ? raw.schema : 0 };
240
- }
241
-
242
- /**
243
- * Validate and repair a registry.
244
- *
245
- * Repair-oriented like `persona.js`: the only hard errors are "not an object"
246
- * and "schema from the future", because this is a file the user can hand-edit
247
- * and a bad row must not take the app down. Everything else is coerced, dropped
248
- * or repaired and reported in `warnings`.
249
- *
250
- * @returns {{ ok: boolean, registry: object, errors: string[], warnings: string[], migrated: boolean }}
251
- */
252
- function validate(raw) {
253
- const errors = [];
254
- const warnings = [];
255
-
256
- if (raw === undefined || raw === null) {
257
- return { ok: true, registry: emptyRegistry(), errors, warnings: ['no holders registry yet — starting empty'], migrated: false };
258
- }
259
- if (typeof raw !== 'object' || Array.isArray(raw)) {
260
- return { ok: false, registry: emptyRegistry(), errors: ['holders.json must be a JSON object'], warnings, migrated: false };
261
- }
262
- if (Number.isInteger(raw.schema) && raw.schema > SCHEMA_VERSION) {
263
- return {
264
- ok: false,
265
- registry: emptyRegistry(),
266
- errors: [`holders schema ${raw.schema} is newer than this build supports (${SCHEMA_VERSION})`],
267
- warnings,
268
- migrated: false,
269
- };
270
- }
271
-
272
- const { registry: migratedRegistry, migrated } = migrate(raw);
273
- const src = migratedRegistry;
274
-
275
- const rows = [];
276
- const seen = new Set();
277
- const rawHolders = Array.isArray(src.holders) ? src.holders : [];
278
- if (src.holders !== undefined && !Array.isArray(src.holders)) {
279
- warnings.push('holders must be an array — ignored');
280
- }
281
- for (const rawRow of rawHolders) {
282
- if (rows.length >= MAX_HOLDERS) {
283
- warnings.push(`at most ${MAX_HOLDERS} holders — extra rows dropped`);
284
- break;
285
- }
286
- if (!rawRow || typeof rawRow !== 'object' || Array.isArray(rawRow)) {
287
- warnings.push('a holder row was not an object — dropped');
288
- continue;
289
- }
290
- const id = holderIdShape(rawRow.id);
291
- if (!id) {
292
- warnings.push(`holder id ${safeLabel(rawRow.id)} is not h_ plus 8-16 hex — row dropped`);
293
- continue;
294
- }
295
- if (seen.has(id)) {
296
- warnings.push(`duplicate holder id ${id} — the later row was dropped`);
297
- continue;
298
- }
299
- seen.add(id);
300
-
301
- let fingerprintValue = typeof rawRow.fingerprint === 'string' ? rawRow.fingerprint.trim().toLowerCase() : null;
302
- if (fingerprintValue && !FINGERPRINT_RE.test(fingerprintValue)) {
303
- // The row survives; the bad fingerprint does not. A malformed digest is a
304
- // repair, not a reason to lose the holder's display name and timestamps.
305
- warnings.push(`holder ${id} fingerprint is not ${FINGERPRINT_LENGTH} lowercase hex — dropped from the row`);
306
- fingerprintValue = null;
307
- }
308
-
309
- let accountId = null;
310
- if (rawRow.accountId !== undefined && rawRow.accountId !== null) {
311
- const candidate = typeof rawRow.accountId === 'string' ? rawRow.accountId.trim() : '';
312
- if (candidate && ACCOUNT_ID_RE.test(candidate) && !looksLikeRawKey(candidate)) accountId = candidate;
313
- else warnings.push(`holder ${id} accountId was rejected by shape`);
314
- }
315
-
316
- rows.push({
317
- id,
318
- displayName: cleanDisplay(rawRow.displayName, id),
319
- fingerprint: fingerprintValue,
320
- accountId,
321
- personaName: cleanDisplay(rawRow.personaName, null),
322
- createdAt: numOrNull(rawRow.createdAt),
323
- lastSeenAt: numOrNull(rawRow.lastSeenAt),
324
- });
325
- }
326
-
327
- // Two rows on one fingerprint is exactly the case that makes "a fingerprint
328
- // is not an identity" load-bearing (§3/§6): it is legal, and resolve() reports
329
- // it rather than merging the rows.
330
- const byFingerprint = new Map();
331
- for (const row of rows) {
332
- if (!row.fingerprint) continue;
333
- if (byFingerprint.has(row.fingerprint)) {
334
- warnings.push(`holders ${byFingerprint.get(row.fingerprint)} and ${row.id} share one key fingerprint — resolve() proposes, never merges`);
335
- } else {
336
- byFingerprint.set(row.fingerprint, row.id);
337
- }
338
- }
339
-
340
- let active = typeof src.active === 'string' ? holderIdShape(src.active) : null;
341
- if (src.active !== undefined && src.active !== null && src.active !== '' && !active) {
342
- warnings.push(`active ${safeLabel(src.active)} is not a holder id — repaired`);
343
- }
344
- if (active && !rows.some((row) => row.id === active)) {
345
- warnings.push(`active holder ${active} is not in the registry — repaired`);
346
- active = null;
347
- }
348
- if (!active && rows.length) active = rows[0].id;
349
-
350
- for (const key of Object.keys(src)) {
351
- if (!['schema', 'active', 'holders'].includes(key)) {
352
- warnings.push(`unknown registry key ${safeLabel(key)} — ignored`);
353
- }
354
- }
355
-
356
- return { ok: true, errors, warnings, migrated, registry: { schema: SCHEMA_VERSION, active, holders: rows } };
357
- }
358
-
359
- /** Convenience: validate and hand back just the record. */
360
- function load(raw) {
361
- return validate(raw).registry;
362
- }
363
-
364
- /** Shallow-merge a patch onto a valid registry, then re-validate. */
365
- function update(current, patch) {
366
- const base = validate(current).registry;
367
- if (!patch || typeof patch !== 'object') return validate(base);
368
- const merged = {
369
- schema: SCHEMA_VERSION,
370
- active: patch.active !== undefined ? patch.active : base.active,
371
- holders: patch.holders !== undefined ? patch.holders : base.holders,
372
- };
373
- return validate(merged);
374
- }
375
-
376
- function findHolder(registry, id) {
377
- const wanted = holderIdShape(id);
378
- if (!wanted) return null;
379
- return load(registry).holders.find((row) => row.id === wanted) || null;
380
- }
381
-
382
- function findByFingerprint(registry, value) {
383
- if (!isFingerprint(value)) return null;
384
- const wanted = String(value).trim().toLowerCase();
385
- return load(registry).holders.find((row) => row.fingerprint === wanted) || null;
386
- }
387
-
388
- /** Every row on one fingerprint, id-ascending. Usually 0 or 1; 2 is legal. */
389
- function allByFingerprint(registry, value) {
390
- if (!isFingerprint(value)) return [];
391
- const wanted = String(value).trim().toLowerCase();
392
- return load(registry)
393
- .holders.filter((row) => row.fingerprint === wanted)
394
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
395
- }
396
-
397
- function findByAccount(registry, value) {
398
- if (typeof value !== 'string' || !value.trim()) return null;
399
- const wanted = value.trim();
400
- return load(registry).holders.find((row) => row.accountId === wanted) || null;
401
- }
402
-
403
- function hasFingerprint(registry, value) {
404
- return Boolean(findByFingerprint(registry, value));
405
- }
406
-
407
- /**
408
- * Append a holder row. An invalid row comes back as a warning on an unchanged
409
- * registry rather than a throw — the same contract as `persona.validate`.
410
- * The first valid row also becomes `active`, because a registry with one holder
411
- * and no active holder is a state main would have to guess its way out of.
412
- */
413
- function addHolder(current, holder) {
414
- const base = validate(current).registry;
415
- if (base.holders.length >= MAX_HOLDERS) {
416
- return update(base, {});
417
- }
418
- const next = update(base, { holders: base.holders.concat([holder]) });
419
- if (!next.registry.active && next.registry.holders.length === base.holders.length + 1) {
420
- return update(next.registry, { active: next.registry.holders[next.registry.holders.length - 1].id });
421
- }
422
- return next;
423
- }
424
-
425
- function setActive(current, id) {
426
- const base = validate(current).registry;
427
- const wanted = holderIdShape(id);
428
- if (wanted && base.holders.some((row) => row.id === wanted)) return update(base, { active: wanted });
429
- return validate(base);
430
- }
431
-
432
- /** «forget»: drop one row, and re-point `active` if it was that row. */
433
- function removeHolder(current, id) {
434
- const base = validate(current).registry;
435
- const wanted = holderIdShape(id);
436
- if (!wanted || !base.holders.some((row) => row.id === wanted)) return validate(base);
437
- const holders = base.holders.filter((row) => row.id !== wanted);
438
- return update(base, { holders, active: base.active === wanted ? null : base.active });
439
- }
440
-
441
- /** Record a sighting. `t` is caller-supplied so this stays clock-free. */
442
- function touch(current, id, t) {
443
- const base = validate(current).registry;
444
- const wanted = holderIdShape(id);
445
- const when = numOrNull(t);
446
- if (!wanted || when === null || !base.holders.some((row) => row.id === wanted)) return validate(base);
447
- const holders = base.holders.map((row) => (
448
- row.id === wanted ? Object.assign({}, row, { lastSeenAt: Math.max(row.lastSeenAt ?? when, when) }) : row
449
- ));
450
- return update(base, { holders });
451
- }
452
-
453
- // ---------------------------------------------------------------------------
454
- // resolution
455
- // ---------------------------------------------------------------------------
456
-
457
- /** Per-rung confidence: how much the signal actually proves (identity plan §2). */
458
- const CONFIDENCE = Object.freeze({
459
- explicit: 1,
460
- account: 0.9,
461
- keyFingerprint: 0.6,
462
- local: 0.4,
463
- });
464
-
465
- /**
466
- * Decide which holder is at the keyboard.
467
- *
468
- * @param {object} signals
469
- * @param {string} [signals.explicit] `AEGIS_AVATAR_HOLDER` or persona `identity.holderId`
470
- * @param {string} [signals.account] cloud account id, when the server gives one
471
- * @param {string} [signals.keyFingerprint] output of `fingerprint()` — never a key
472
- * @param {string} [signals.local] the minted id main keeps in `__avatar` settings
473
- * @param {string} [signals.osUser] display hint ONLY (rule 3)
474
- * @param {string} [signals.machineSeed] stable per-machine entropy for `mintHolderId`
475
- * @param {(s: string) => string|Uint8Array} [signals.hash] the caller's digest
476
- * @param {object} [registry] parsed (or unparsed) `holders.json`
477
- * @returns {{ holderId: string|null, source: string|null, confidence: number,
478
- * displayName: string, conflict: {a: string, b: string, reason: string}|null,
479
- * known: boolean, needsMint: boolean, proposal: object|null, warnings: string[] }}
480
- * `conflict.a` is what you would be switching away from (the registry's
481
- * active holder, or the losing signal's holder) and `conflict.b` is the
482
- * proposal. Returned so main can show a "who is at the keyboard?" card
483
- * instead of switching silently.
484
- */
485
- function resolve(signals, registry) {
486
- const raw = signals && typeof signals === 'object' ? signals : {};
487
- const reg = load(registry);
488
- const active = reg.active;
489
- const warnings = [];
490
-
491
- // --- signal intake: every rung is shape-checked before it is believed ----
492
- const explicit = holderIdShape(raw.explicit);
493
- if (raw.explicit !== undefined && raw.explicit !== null && raw.explicit !== '' && !explicit) {
494
- warnings.push('explicit holder id ignored: expected h_ plus 8-16 hex');
495
- }
496
-
497
- let account = null;
498
- if (raw.account !== undefined && raw.account !== null && raw.account !== '') {
499
- const candidate = typeof raw.account === 'string' ? raw.account.trim() : '';
500
- if (candidate && ACCOUNT_ID_RE.test(candidate) && !looksLikeRawKey(candidate)) account = candidate;
501
- else warnings.push('account signal ignored: rejected by shape');
502
- }
503
-
504
- let keyFingerprint = null;
505
- if (raw.keyFingerprint !== undefined && raw.keyFingerprint !== null && raw.keyFingerprint !== '') {
506
- // Rule 2, the load-bearing one: an already-computed fingerprint or nothing.
507
- if (isFingerprint(raw.keyFingerprint)) keyFingerprint = String(raw.keyFingerprint).trim().toLowerCase();
508
- else warnings.push('keyFingerprint ignored: expected the 12-hex output of fingerprint(), not a key or a full digest');
509
- }
510
-
511
- const local = holderIdShape(raw.local);
512
- if (raw.local !== undefined && raw.local !== null && raw.local !== '' && !local) {
513
- warnings.push('local holder id ignored: expected h_ plus 8-16 hex');
514
- }
515
-
516
- // Rule 3: the OS user name reaches `displayName` and nothing else.
517
- const hint = cleanDisplay(raw.osUser, null);
518
- const hash = typeof raw.hash === 'function' ? raw.hash : null;
519
- const seed = typeof raw.machineSeed === 'string' && raw.machineSeed.trim() ? raw.machineSeed : null;
520
-
521
- function finish(parts) {
522
- const holderId = parts.holderId || null;
523
- const row = holderId ? reg.holders.find((r) => r.id === holderId) || null : null;
524
- // A fingerprint that matches no registry row proves nothing about who this
525
- // is, so it can never *select* a holder. It can only be proposed for
526
- // binding onto the holder that WAS resolved, which main writes only after
527
- // the user confirms — this module never writes anything. Built
528
- // here rather than in the last rung so every rung reports it identically:
529
- // an explicit persona import with a freshly rotated key is the same
530
- // question as a fresh install with one.
531
- const proposal = holderId && keyFingerprint && !hasFingerprint(reg, keyFingerprint)
532
- ? { kind: 'bind-fingerprint', holderId, fingerprint: keyFingerprint }
533
- : null;
534
- return {
535
- holderId,
536
- source: holderId ? parts.source : null,
537
- confidence: holderId ? parts.confidence : 0,
538
- displayName: row ? row.displayName : hint || DEFAULT_DISPLAY_NAME,
539
- conflict: parts.conflict || null,
540
- known: Boolean(row),
541
- needsMint: Boolean(parts.needsMint),
542
- proposal,
543
- warnings,
544
- };
545
- }
546
-
547
- // --- rung 1: deliberate beats inferred --------------------------------
548
- if (explicit) {
549
- return finish({ holderId: explicit, source: 'explicit', confidence: CONFIDENCE.explicit });
550
- }
551
-
552
- const byAccount = account ? findByAccount(reg, account) : null;
553
- const fpRows = keyFingerprint ? allByFingerprint(reg, keyFingerprint) : [];
554
- const byFingerprint = fpRows.length ? (fpRows.find((row) => row.id === active) || fpRows[0]) : null;
555
-
556
- // --- rung 2: account (survives key rotation) ---------------------------
557
- if (byAccount && byFingerprint && byAccount.id !== byFingerprint.id) {
558
- // Both strong signals landed on existing holders, and they disagree. The
559
- // account is the stronger claim; the disagreement is reported, not hidden.
560
- return finish({
561
- holderId: byAccount.id,
562
- source: 'account',
563
- confidence: CONFIDENCE.account,
564
- conflict: { a: byFingerprint.id, b: byAccount.id, reason: 'account-vs-fingerprint' },
565
- });
566
- }
567
- if (byFingerprint && fpRows.length > 1) {
568
- // One credential, two rows: legal (a shared BYOK key) and never merged.
569
- const chosen = fpRows.find((row) => row.id === active) || fpRows[0];
570
- const other = fpRows.find((row) => row.id !== chosen.id);
571
- return finish({
572
- holderId: chosen.id,
573
- source: 'keyFingerprint',
574
- confidence: CONFIDENCE.keyFingerprint,
575
- conflict: { a: other.id, b: chosen.id, reason: 'duplicate-fingerprint' },
576
- });
577
- }
578
- if (byAccount) {
579
- return finish({
580
- holderId: byAccount.id,
581
- source: 'account',
582
- confidence: CONFIDENCE.account,
583
- conflict: active && active !== byAccount.id ? { a: active, b: byAccount.id, reason: 'account' } : null,
584
- });
585
- }
586
- if (byFingerprint) {
587
- return finish({
588
- holderId: byFingerprint.id,
589
- source: 'keyFingerprint',
590
- confidence: CONFIDENCE.keyFingerprint,
591
- // A fingerprint match PROPOSES a rebind. Both rows survive; main asks.
592
- conflict: active && active !== byFingerprint.id ? { a: active, b: byFingerprint.id, reason: 'key-fingerprint' } : null,
593
- });
594
- }
595
-
596
- // --- rung 4: local ------------------------------------------------------
597
- let holderId = local || holderIdShape(active);
598
- let needsMint = false;
599
- if (!holderId) {
600
- holderId = hash && seed ? mintHolderId(hash, seed) : null;
601
- if (!holderId) {
602
- needsMint = true;
603
- warnings.push('no holder signal is available — main must mint a local holder id');
604
- }
605
- }
606
-
607
- // A fingerprint that matches no row cannot select a holder here; `finish()`
608
- // turns it into a bind proposal for whichever holder did resolve.
609
- return finish({ holderId, source: 'local', confidence: CONFIDENCE.local, needsMint });
610
- }
611
-
612
- module.exports = {
613
- SCHEMA_VERSION,
614
- FINGERPRINT_LENGTH,
615
- MAX_HOLDERS,
616
- MAX_DISPLAY_NAME,
617
- DEFAULT_DISPLAY_NAME,
618
- CONFIDENCE,
619
- HOLDER_ID_RE,
620
- HOLDER_ID_SHAPE,
621
- FINGERPRINT_RE,
622
- fingerprint,
623
- mintHolderId,
624
- isFingerprint,
625
- looksLikeRawKey,
626
- safeLabel,
627
- cleanDisplay,
628
- emptyRegistry,
629
- migrate,
630
- validate,
631
- load,
632
- update,
633
- findHolder,
634
- findByFingerprint,
635
- allByFingerprint,
636
- findByAccount,
637
- hasFingerprint,
638
- addHolder,
639
- setActive,
640
- removeHolder,
641
- touch,
642
- resolve,
643
- };
3
+ "use strict";const persona=require("./persona.js"),SCHEMA_VERSION=1,FINGERPRINT_LENGTH=12,MAX_HOLDERS=32,MAX_DISPLAY_NAME=40,MAX_ACCOUNT_ID=64,HOLDER_ID_RE=/^h_[0-9a-f]{12}$/,HOLDER_ID_SHAPE=/^h_[0-9a-f]{8,16}$/,FINGERPRINT_RE=/^[0-9a-f]{12}$/,ACCOUNT_ID_RE=/^[A-Za-z0-9][A-Za-z0-9._:-]{3,63}$/,KEY_SHAPE=/^(?:sk|pk|gsk|xox[baprs]|ghp|glpat|aegis)[-_][A-Za-z0-9_-]{6,}/i,LONG_HEX=/[0-9a-f]{20,}/i,BEARER=/^Bearer\s+\S+/i,DEFAULT_DISPLAY_NAME="You";function toHex(e){if("string"==typeof e){const r=e.trim().toLowerCase().replace(/\s+/g,"");return/^[0-9a-f]+$/.test(r)?r:null}if(e instanceof Uint8Array||Array.isArray(e)){let r="";for(const t of e){const e=Number(t);if(!Number.isInteger(e)||e<0||e>255)return null;r+=e.toString(16).padStart(2,"0")}return r||null}return null}function fingerprint(e,r){if("function"!=typeof e)return null;if("string"!=typeof r)return null;const t=r.trim();if(!t)return null;let n;try{n=e(t)}catch{return null}const i=toHex(n);return!i||i.length<FINGERPRINT_LENGTH?null:i.slice(0,FINGERPRINT_LENGTH)}function isFingerprint(e){return"string"==typeof e&&FINGERPRINT_RE.test(e.trim().toLowerCase())}function looksLikeRawKey(e){if("string"!=typeof e)return!1;const r=e.trim();return!!r&&(KEY_SHAPE.test(r)||BEARER.test(r)||LONG_HEX.test(r))}function mintHolderId(e,r){if("function"!=typeof e)return null;if("string"!=typeof r||!r.trim())return null;let t;try{t=e(r.trim())}catch{return null}const n=toHex(t);if(!n||n.length<FINGERPRINT_LENGTH)return null;const i=`h_${n.slice(0,FINGERPRINT_LENGTH)}`;return HOLDER_ID_RE.test(i)?i:null}function cleanDisplay(e,r=DEFAULT_DISPLAY_NAME){return"string"!=typeof e||looksLikeRawKey(e)?r:persona.cleanText(e,MAX_DISPLAY_NAME)||r}function numOrNull(e){return"number"==typeof e&&Number.isFinite(e)?e:null}function safeLabel(e){if("string"!=typeof e||!e.trim())return"(empty)";const r=e.trim();return r.length>24||looksLikeRawKey(r)?"(withheld)":r}function holderIdShape(e){if("string"!=typeof e)return null;const r=e.trim().toLowerCase();return HOLDER_ID_SHAPE.test(r)?r:null}function emptyRegistry(){return{schema:SCHEMA_VERSION,active:null,holders:[]}}const MIGRATIONS=Object.freeze({0:e=>Object.assign({},e)});function migrate(e){if(!e||"object"!=typeof e)return{registry:emptyRegistry(),migrated:!1,fromSchema:0};let r=e,t=Number.isInteger(r.schema)?r.schema:0,n=!1;for(;t<SCHEMA_VERSION;){const e=MIGRATIONS[t];r=e?e(r):r,t+=1,n=!0}return{registry:r,migrated:n,fromSchema:Number.isInteger(e.schema)?e.schema:0}}function validate(e){const r=[],t=[];if(null==e)return{ok:!0,registry:emptyRegistry(),errors:r,warnings:["no holders registry yet — starting empty"],migrated:!1};if("object"!=typeof e||Array.isArray(e))return{ok:!1,registry:emptyRegistry(),errors:["holders.json must be a JSON object"],warnings:t,migrated:!1};if(Number.isInteger(e.schema)&&e.schema>SCHEMA_VERSION)return{ok:!1,registry:emptyRegistry(),errors:[`holders schema ${e.schema} is newer than this build supports (${SCHEMA_VERSION})`],warnings:t,migrated:!1};const{registry:n,migrated:i}=migrate(e),o=n,l=[],s=new Set,a=Array.isArray(o.holders)?o.holders:[];void 0===o.holders||Array.isArray(o.holders)||t.push("holders must be an array — ignored");for(const e of a){if(l.length>=MAX_HOLDERS){t.push(`at most ${MAX_HOLDERS} holders — extra rows dropped`);break}if(!e||"object"!=typeof e||Array.isArray(e)){t.push("a holder row was not an object — dropped");continue}const r=holderIdShape(e.id);if(!r){t.push(`holder id ${safeLabel(e.id)} is not h_ plus 8-16 hex — row dropped`);continue}if(s.has(r)){t.push(`duplicate holder id ${r} — the later row was dropped`);continue}s.add(r);let n="string"==typeof e.fingerprint?e.fingerprint.trim().toLowerCase():null;n&&!FINGERPRINT_RE.test(n)&&(t.push(`holder ${r} fingerprint is not ${FINGERPRINT_LENGTH} lowercase hex — dropped from the row`),n=null);let i=null;if(void 0!==e.accountId&&null!==e.accountId){const n="string"==typeof e.accountId?e.accountId.trim():"";n&&ACCOUNT_ID_RE.test(n)&&!looksLikeRawKey(n)?i=n:t.push(`holder ${r} accountId was rejected by shape`)}l.push({id:r,displayName:cleanDisplay(e.displayName,r),fingerprint:n,accountId:i,personaName:cleanDisplay(e.personaName,null),createdAt:numOrNull(e.createdAt),lastSeenAt:numOrNull(e.lastSeenAt)})}const d=new Map;for(const e of l)e.fingerprint&&(d.has(e.fingerprint)?t.push(`holders ${d.get(e.fingerprint)} and ${e.id} share one key fingerprint — resolve() proposes, never merges`):d.set(e.fingerprint,e.id));let c="string"==typeof o.active?holderIdShape(o.active):null;void 0===o.active||null===o.active||""===o.active||c||t.push(`active ${safeLabel(o.active)} is not a holder id — repaired`),c&&!l.some((e=>e.id===c))&&(t.push(`active holder ${c} is not in the registry — repaired`),c=null),!c&&l.length&&(c=l[0].id);for(const e of Object.keys(o))["schema","active","holders"].includes(e)||t.push(`unknown registry key ${safeLabel(e)} — ignored`);return{ok:!0,errors:r,warnings:t,migrated:i,registry:{schema:SCHEMA_VERSION,active:c,holders:l}}}function load(e){return validate(e).registry}function update(e,r){const t=validate(e).registry;return validate(r&&"object"==typeof r?{schema:SCHEMA_VERSION,active:void 0!==r.active?r.active:t.active,holders:void 0!==r.holders?r.holders:t.holders}:t)}function findHolder(e,r){const t=holderIdShape(r);return t&&load(e).holders.find((e=>e.id===t))||null}function findByFingerprint(e,r){if(!isFingerprint(r))return null;const t=String(r).trim().toLowerCase();return load(e).holders.find((e=>e.fingerprint===t))||null}function allByFingerprint(e,r){if(!isFingerprint(r))return[];const t=String(r).trim().toLowerCase();return load(e).holders.filter((e=>e.fingerprint===t)).sort(((e,r)=>e.id<r.id?-1:e.id>r.id?1:0))}function findByAccount(e,r){if("string"!=typeof r||!r.trim())return null;const t=r.trim();return load(e).holders.find((e=>e.accountId===t))||null}function hasFingerprint(e,r){return Boolean(findByFingerprint(e,r))}function addHolder(e,r){const t=validate(e).registry;if(t.holders.length>=MAX_HOLDERS)return update(t,{});const n=update(t,{holders:t.holders.concat([r])});return n.registry.active||n.registry.holders.length!==t.holders.length+1?n:update(n.registry,{active:n.registry.holders[n.registry.holders.length-1].id})}function setActive(e,r){const t=validate(e).registry,n=holderIdShape(r);return n&&t.holders.some((e=>e.id===n))?update(t,{active:n}):validate(t)}function removeHolder(e,r){const t=validate(e).registry,n=holderIdShape(r);if(!n||!t.holders.some((e=>e.id===n)))return validate(t);const i=t.holders.filter((e=>e.id!==n));return update(t,{holders:i,active:t.active===n?null:t.active})}function touch(e,r,t){const n=validate(e).registry,i=holderIdShape(r),o=numOrNull(t);if(!i||null===o||!n.holders.some((e=>e.id===i)))return validate(n);const l=n.holders.map((e=>e.id===i?Object.assign({},e,{lastSeenAt:Math.max(e.lastSeenAt??o,o)}):e));return update(n,{holders:l})}const CONFIDENCE=Object.freeze({explicit:1,account:.9,keyFingerprint:.6,local:.4});function resolve(e,r){const t=e&&"object"==typeof e?e:{},n=load(r),i=n.active,o=[],l=holderIdShape(t.explicit);void 0===t.explicit||null===t.explicit||""===t.explicit||l||o.push("explicit holder id ignored: expected h_ plus 8-16 hex");let s=null;if(void 0!==t.account&&null!==t.account&&""!==t.account){const e="string"==typeof t.account?t.account.trim():"";e&&ACCOUNT_ID_RE.test(e)&&!looksLikeRawKey(e)?s=e:o.push("account signal ignored: rejected by shape")}let a=null;void 0!==t.keyFingerprint&&null!==t.keyFingerprint&&""!==t.keyFingerprint&&(isFingerprint(t.keyFingerprint)?a=String(t.keyFingerprint).trim().toLowerCase():o.push("keyFingerprint ignored: expected the 12-hex output of fingerprint(), not a key or a full digest"));const d=holderIdShape(t.local);void 0===t.local||null===t.local||""===t.local||d||o.push("local holder id ignored: expected h_ plus 8-16 hex");const c=cleanDisplay(t.osUser,null),u="function"==typeof t.hash?t.hash:null,p="string"==typeof t.machineSeed&&t.machineSeed.trim()?t.machineSeed:null;function f(e){const r=e.holderId||null,t=r&&n.holders.find((e=>e.id===r))||null,i=r&&a&&!hasFingerprint(n,a)?{kind:"bind-fingerprint",holderId:r,fingerprint:a}:null;return{holderId:r,source:r?e.source:null,confidence:r?e.confidence:0,displayName:t?t.displayName:c||DEFAULT_DISPLAY_NAME,conflict:e.conflict||null,known:Boolean(t),needsMint:Boolean(e.needsMint),proposal:i,warnings:o}}if(l)return f({holderId:l,source:"explicit",confidence:CONFIDENCE.explicit});const h=s?findByAccount(n,s):null,g=a?allByFingerprint(n,a):[],y=g.length?g.find((e=>e.id===i))||g[0]:null;if(h&&y&&h.id!==y.id)return f({holderId:h.id,source:"account",confidence:CONFIDENCE.account,conflict:{a:y.id,b:h.id,reason:"account-vs-fingerprint"}});if(y&&g.length>1){const e=g.find((e=>e.id===i))||g[0],r=g.find((r=>r.id!==e.id));return f({holderId:e.id,source:"keyFingerprint",confidence:CONFIDENCE.keyFingerprint,conflict:{a:r.id,b:e.id,reason:"duplicate-fingerprint"}})}if(h)return f({holderId:h.id,source:"account",confidence:CONFIDENCE.account,conflict:i&&i!==h.id?{a:i,b:h.id,reason:"account"}:null});if(y)return f({holderId:y.id,source:"keyFingerprint",confidence:CONFIDENCE.keyFingerprint,conflict:i&&i!==y.id?{a:i,b:y.id,reason:"key-fingerprint"}:null});let E=d||holderIdShape(i),I=!1;return E||(E=u&&p?mintHolderId(u,p):null,E||(I=!0,o.push("no holder signal is available — main must mint a local holder id"))),f({holderId:E,source:"local",confidence:CONFIDENCE.local,needsMint:I})}module.exports={SCHEMA_VERSION:SCHEMA_VERSION,FINGERPRINT_LENGTH:FINGERPRINT_LENGTH,MAX_HOLDERS:MAX_HOLDERS,MAX_DISPLAY_NAME:MAX_DISPLAY_NAME,DEFAULT_DISPLAY_NAME:DEFAULT_DISPLAY_NAME,CONFIDENCE:CONFIDENCE,HOLDER_ID_RE:HOLDER_ID_RE,HOLDER_ID_SHAPE:HOLDER_ID_SHAPE,FINGERPRINT_RE:FINGERPRINT_RE,fingerprint:fingerprint,mintHolderId:mintHolderId,isFingerprint:isFingerprint,looksLikeRawKey:looksLikeRawKey,safeLabel:safeLabel,cleanDisplay:cleanDisplay,emptyRegistry:emptyRegistry,migrate:migrate,validate:validate,load:load,update:update,findHolder:findHolder,findByFingerprint:findByFingerprint,allByFingerprint:allByFingerprint,findByAccount:findByAccount,hasFingerprint:hasFingerprint,addHolder:addHolder,setActive:setActive,removeHolder:removeHolder,touch:touch,resolve:resolve};