aegis-desktop 0.3.0

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.
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * settings.js — provider-config store for the desktop host (plan P1 §5.1).
5
+ * Holds base URLs and provider keys in the main process only. Keys are
6
+ * encrypted at rest via Electron safeStorage when available, otherwise stored
7
+ * base64 (best-effort; never real security) — and only masked previews ever
8
+ * cross the IPC bridge.
9
+ *
10
+ * The in-app AEGIS key lives in its own reserved namespace (AEGIS_KEY_NAMESPACE)
11
+ * with dedicated accessors, so the provider CRUD surface — and the Settings
12
+ * pane built on it — can never list it as a provider or remove it.
13
+ *
14
+ * Pure Node + injectable storage dir / safeStorage so it unit-tests without
15
+ * an Electron binary.
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const SETTINGS_FILE = 'settings.json';
22
+
23
+ /**
24
+ * Reserved namespace for the in-app AEGIS API key. It deliberately does NOT
25
+ * look like a provider id: the provider-config surface (get/set/remove/list,
26
+ * and therefore the Settings pane and the model: IPC) must never be able to
27
+ * list it as a provider or delete it via "Remove" — that coupling is defect
28
+ * #1 of the AEGIS-key wiring review (settings.set('aegis', …) used the same
29
+ * namespace as user provider configs). Use the dedicated AEGIS accessors on
30
+ * the store (setAegisKey / aegisRawKey / aegisKey) to touch it.
31
+ */
32
+ const AEGIS_KEY_NAMESPACE = '__aegis';
33
+
34
+ /** Pre-fix builds persisted the AEGIS key as a plain provider named 'aegis';
35
+ * migrateLegacyAegisKey() relocates it (and list() hides it meanwhile). */
36
+ const LEGACY_AEGIS_NAMESPACE = 'aegis';
37
+
38
+ /** Namespaces the provider-config surface must never see or mutate. */
39
+ const RESERVED_NAMESPACES = Object.freeze([
40
+ AEGIS_KEY_NAMESPACE,
41
+ LEGACY_AEGIS_NAMESPACE,
42
+ ]);
43
+
44
+ /** True for the AEGIS-key namespace(s) — provider CRUD must refuse these. */
45
+ function isReservedNamespace(provider) {
46
+ return RESERVED_NAMESPACES.includes(provider);
47
+ }
48
+
49
+ /** Same masking shape as desktop/main.js so previews are consistent. */
50
+ function maskKey(key) {
51
+ if (!key) return null;
52
+ if (key.length <= 10) return 'configured';
53
+ return `${key.slice(0, 9)}\u2026${key.slice(-4)}`;
54
+ }
55
+
56
+ function createSettingsStore({ dir, safeStorage } = {}) {
57
+ if (!dir) throw new Error('createSettingsStore requires a storage dir');
58
+ const file = path.join(dir, SETTINGS_FILE);
59
+
60
+ const safeAvailable = () =>
61
+ Boolean(
62
+ safeStorage &&
63
+ typeof safeStorage.isEncryptionAvailable === 'function' &&
64
+ safeStorage.isEncryptionAvailable()
65
+ );
66
+
67
+ function encrypt(value) {
68
+ if (value == null) return null;
69
+ if (safeAvailable()) {
70
+ return safeStorage.encryptString(String(value)).toString('base64');
71
+ }
72
+ return Buffer.from(String(value), 'utf8').toString('base64');
73
+ }
74
+
75
+ function decrypt(value) {
76
+ if (!value) return null;
77
+ try {
78
+ const buf = Buffer.from(String(value), 'base64');
79
+ if (safeAvailable()) return safeStorage.decryptString(buf);
80
+ return buf.toString('utf8');
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function load() {
87
+ try {
88
+ return JSON.parse(fs.readFileSync(file, 'utf8')) || {};
89
+ } catch {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ function save(data) {
95
+ fs.mkdirSync(path.dirname(file), { recursive: true });
96
+ const tmp = `${file}.tmp-${process.pid}`;
97
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
98
+ fs.renameSync(tmp, file);
99
+ }
100
+
101
+ /** Provider CRUD must never touch a reserved (AEGIS-key) namespace. */
102
+ function assertNotReserved(provider) {
103
+ if (isReservedNamespace(provider)) {
104
+ throw new Error(
105
+ `reserved namespace "${provider}" is not a provider config — ` +
106
+ 'use setAegisKey()/aegisRawKey() for the AEGIS key'
107
+ );
108
+ }
109
+ }
110
+
111
+ function get(provider) {
112
+ const cfg = (load()[provider]) || {};
113
+ const key = decrypt(cfg.key);
114
+ return {
115
+ provider,
116
+ baseURL: cfg.baseURL || '',
117
+ configured: Boolean(key),
118
+ keyMask: maskKey(key),
119
+ };
120
+ }
121
+
122
+ function set(provider, { baseURL, key } = {}) {
123
+ assertNotReserved(provider);
124
+ const data = load();
125
+ const cfg = data[provider] || {};
126
+ if (baseURL !== undefined) cfg.baseURL = baseURL;
127
+ if (key !== undefined) cfg.key = key ? encrypt(key) : null;
128
+ data[provider] = cfg;
129
+ save(data);
130
+ return get(provider);
131
+ }
132
+
133
+ function rawKey(provider) {
134
+ const cfg = load()[provider] || {};
135
+ return decrypt(cfg.key);
136
+ }
137
+
138
+ function remove(provider) {
139
+ assertNotReserved(provider);
140
+ const data = load();
141
+ delete data[provider];
142
+ save(data);
143
+ return { ok: true };
144
+ }
145
+
146
+ /** Every provider config, never the AEGIS key (defect #1). */
147
+ function list() {
148
+ const data = load();
149
+ return Object.keys(data)
150
+ .filter((p) => !isReservedNamespace(p))
151
+ .map((p) => get(p));
152
+ }
153
+
154
+ // --- AEGIS key: separate namespace, separate accessors -------------------
155
+
156
+ function aegisKey() {
157
+ const cfg = load()[AEGIS_KEY_NAMESPACE] || {};
158
+ const key = decrypt(cfg.key);
159
+ return { configured: Boolean(key), keyMask: maskKey(key) };
160
+ }
161
+
162
+ /** Main-process-only: the decrypted AEGIS key. Never crosses IPC. */
163
+ function aegisRawKey() {
164
+ const cfg = load()[AEGIS_KEY_NAMESPACE] || {};
165
+ return decrypt(cfg.key);
166
+ }
167
+
168
+ function setAegisKey(key) {
169
+ const data = load();
170
+ data[AEGIS_KEY_NAMESPACE] = {
171
+ ...(data[AEGIS_KEY_NAMESPACE] || {}),
172
+ key: key ? encrypt(key) : null,
173
+ };
174
+ save(data);
175
+ return aegisKey();
176
+ }
177
+
178
+ /**
179
+ * Relocate a pre-fix `settings['aegis'].key` into the reserved namespace and
180
+ * drop the pseudo-provider entry. Moved at ciphertext level (no decrypt /
181
+ * re-encrypt), so it is safe to run before Electron's app 'ready' — when
182
+ * safeStorage is not usable yet. Idempotent; never throws.
183
+ */
184
+ function migrateLegacyAegisKey() {
185
+ try {
186
+ const data = load();
187
+ if (!data[LEGACY_AEGIS_NAMESPACE]) return { migrated: false };
188
+ const legacy = data[LEGACY_AEGIS_NAMESPACE] || {};
189
+ const current = data[AEGIS_KEY_NAMESPACE] || {};
190
+ const next = { ...data };
191
+ if (legacy.key && !current.key) {
192
+ next[AEGIS_KEY_NAMESPACE] = { ...current, key: legacy.key };
193
+ }
194
+ delete next[LEGACY_AEGIS_NAMESPACE];
195
+ save(next);
196
+ return { migrated: true };
197
+ } catch {
198
+ return { migrated: false };
199
+ }
200
+ }
201
+
202
+ return {
203
+ file,
204
+ get,
205
+ set,
206
+ rawKey,
207
+ remove,
208
+ list,
209
+ maskKey,
210
+ aegisKey,
211
+ aegisRawKey,
212
+ setAegisKey,
213
+ migrateLegacyAegisKey,
214
+ };
215
+ }
216
+
217
+ module.exports = {
218
+ SETTINGS_FILE,
219
+ AEGIS_KEY_NAMESPACE,
220
+ LEGACY_AEGIS_NAMESPACE,
221
+ RESERVED_NAMESPACES,
222
+ isReservedNamespace,
223
+ maskKey,
224
+ createSettingsStore,
225
+ };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * memory-queue.js — local retry queue for cloud memory saves (plan P3 §7).
5
+ * When `aegis.memorySave()` fails (no key configured, offline), the entry is
6
+ * appended to <dir>/memory-queue.json instead of being dropped, so a later
7
+ * "Sync now" or heartbeat retry can flush it once the cloud is reachable.
8
+ * Pure Node + injectable dir, mirrors desktop/lib/sync/sessions.js.
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ function queueFile(dir) {
15
+ return path.join(dir, 'memory-queue.json');
16
+ }
17
+
18
+ function load(dir) {
19
+ try {
20
+ const data = JSON.parse(fs.readFileSync(queueFile(dir), 'utf8'));
21
+ return Array.isArray(data) ? data : [];
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ function atomicWrite(file, data) {
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ const tmp = `${file}.tmp-${process.pid}`;
30
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
31
+ fs.renameSync(tmp, file);
32
+ }
33
+
34
+ function save(dir, entries) {
35
+ atomicWrite(queueFile(dir), entries);
36
+ }
37
+
38
+ /** Queue a memory entry that a cloud save couldn't reach right now. */
39
+ function enqueue(dir, entry) {
40
+ const entries = load(dir);
41
+ const queued = { ...entry, queuedAt: Date.now() };
42
+ entries.push(queued);
43
+ save(dir, entries);
44
+ return queued;
45
+ }
46
+
47
+ function listQueued(dir) {
48
+ return load(dir);
49
+ }
50
+
51
+ module.exports = {
52
+ queueFile,
53
+ load,
54
+ save,
55
+ enqueue,
56
+ listQueued,
57
+ };
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * sessions.js — local conversation persistence (plan P1 §5.1 / P3 §7).
5
+ * Every session (any model class) persists to <dir>/sessions.json on each
6
+ * message with a crash-safe temp-file rename. Pure Node + injectable dir,
7
+ * so it unit-tests without Electron.
8
+ */
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ function sessionsFile(dir) {
14
+ return path.join(dir, 'sessions.json');
15
+ }
16
+
17
+ function load(dir) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(sessionsFile(dir), 'utf8')) || {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Monotonic write counter stored in the file's `__seq` field. Every mutation
27
+ * bumps it, so sessions written in the same millisecond still order
28
+ * deterministically (listSessions sorts by updatedAt, then by seq).
29
+ */
30
+ function nextSeq(sessions) {
31
+ const seq = (typeof sessions.__seq === 'number' ? sessions.__seq : 0) + 1;
32
+ sessions.__seq = seq;
33
+ return seq;
34
+ }
35
+
36
+ /**
37
+ * Normalize a remote `updated_at`/`updatedAt` to epoch milliseconds. The cloud
38
+ * returns an ISO-8601 string (e.g. "2026-07-16T14:20:00"); the local store
39
+ * uses `Date.now()` epoch-ms numbers. Comparing the two directly coerces the
40
+ * string to NaN, which breaks last-write-wins ordering. ISO strings are
41
+ * parsed; epoch-ms numbers pass through; anything else becomes 0.
42
+ */
43
+ function toEpochMs(value) {
44
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
45
+ if (typeof value === 'string' && value.trim()) {
46
+ const ms = Date.parse(value);
47
+ if (Number.isFinite(ms)) return ms;
48
+ }
49
+ return 0;
50
+ }
51
+
52
+ function atomicWrite(file, data) {
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ const tmp = `${file}.tmp-${process.pid}`;
55
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
56
+ fs.renameSync(tmp, file);
57
+ }
58
+
59
+ function save(dir, sessions) {
60
+ atomicWrite(sessionsFile(dir), sessions);
61
+ }
62
+
63
+ /**
64
+ * Create or merge a full session record. Any local write (`upsertSession` /
65
+ * `appendMessage`) marks the session `pending: true` — it has local content
66
+ * the cloud hasn't seen yet. Only `markSynced()` clears the flag, so a push
67
+ * that fails (offline/no key) never silently drops the session from the
68
+ * retry queue.
69
+ */
70
+ function upsertSession(dir, session) {
71
+ const id = session && session.id;
72
+ if (!id) throw new Error('session.id is required');
73
+ const sessions = load(dir);
74
+ const prev = sessions[id] || { messages: [] };
75
+ sessions[id] = { ...prev, ...session, id, pending: true };
76
+ if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
77
+ sessions[id].seq = nextSeq(sessions);
78
+ save(dir, sessions);
79
+ return sessions[id];
80
+ }
81
+
82
+ /** Append one message to a session (crash-safe). */
83
+ function appendMessage(dir, sessionId, message) {
84
+ if (!sessionId) throw new Error('sessionId is required');
85
+ const sessions = load(dir);
86
+ const session = sessions[sessionId] || { id: sessionId, messages: [] };
87
+ const messages = Array.isArray(session.messages) ? session.messages : [];
88
+ session.messages = messages.concat(message);
89
+ session.updatedAt = Date.now();
90
+ session.pending = true;
91
+ session.seq = nextSeq(sessions);
92
+ sessions[sessionId] = session;
93
+ save(dir, sessions);
94
+ return session;
95
+ }
96
+
97
+ function listSessions(dir) {
98
+ const sessions = load(dir);
99
+ return Object.values(sessions)
100
+ .filter((s) => s && s.id)
101
+ .sort(
102
+ (a, b) =>
103
+ (b.updatedAt || 0) - (a.updatedAt || 0) ||
104
+ (b.seq || 0) - (a.seq || 0)
105
+ );
106
+ }
107
+
108
+ function getSession(dir, id) {
109
+ return load(dir)[id] || null;
110
+ }
111
+
112
+ function deleteSession(dir, id) {
113
+ const sessions = load(dir);
114
+ delete sessions[id];
115
+ save(dir, sessions);
116
+ return { ok: true };
117
+ }
118
+
119
+ /** Clear the pending flag after a successful cloud push. `remote.remoteId`,
120
+ * when the server assigns its own conversation id, is stashed alongside. */
121
+ function markSynced(dir, id, remote) {
122
+ const sessions = load(dir);
123
+ const session = sessions[id];
124
+ if (!session) return null;
125
+ session.pending = false;
126
+ session.lastSyncedAt = Date.now();
127
+ if (remote && remote.remoteId) session.remoteId = remote.remoteId;
128
+ session.seq = nextSeq(sessions);
129
+ sessions[id] = session;
130
+ save(dir, sessions);
131
+ return session;
132
+ }
133
+
134
+ /** Force a session back into the retry queue (e.g. a push that partially failed). */
135
+ function markPending(dir, id) {
136
+ const sessions = load(dir);
137
+ const session = sessions[id];
138
+ if (!session) return null;
139
+ session.pending = true;
140
+ session.seq = nextSeq(sessions);
141
+ sessions[id] = session;
142
+ save(dir, sessions);
143
+ return session;
144
+ }
145
+
146
+ /** Sessions with local content the cloud hasn't confirmed yet (including
147
+ * sessions predating this field, which default to pending). */
148
+ function listPending(dir) {
149
+ return listSessions(dir).filter((s) => s.pending !== false);
150
+ }
151
+
152
+ /**
153
+ * Merge remote conversation-sync records into the local store (pull half of
154
+ * sync). Last-write-wins by `updatedAt`, but a local session with unsynced
155
+ * edits (`pending`) always wins over the remote copy — it will overwrite the
156
+ * remote copy on the next push instead.
157
+ */
158
+ function mergeRemoteSessions(dir, remoteSessions) {
159
+ const list = Array.isArray(remoteSessions) ? remoteSessions : [];
160
+ const sessions = load(dir);
161
+ let merged = 0;
162
+ for (const remote of list) {
163
+ const id = remote && (remote.session_id || remote.id);
164
+ if (!id) continue;
165
+ const local = sessions[id];
166
+ const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
167
+ if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
168
+ continue;
169
+ }
170
+ sessions[id] = {
171
+ id,
172
+ title: remote.title || (local && local.title) || '',
173
+ messages: Array.isArray(remote.messages) ? remote.messages : [],
174
+ updatedAt: remoteUpdatedAt || Date.now(),
175
+ pending: false,
176
+ lastSyncedAt: Date.now(),
177
+ remoteId: remote.session_id || remote.id,
178
+ };
179
+ sessions[id].seq = nextSeq(sessions);
180
+ merged += 1;
181
+ }
182
+ if (merged) save(dir, sessions);
183
+ return merged;
184
+ }
185
+
186
+ module.exports = {
187
+ sessionsFile,
188
+ load,
189
+ save,
190
+ upsertSession,
191
+ appendMessage,
192
+ listSessions,
193
+ getSession,
194
+ deleteSession,
195
+ markSynced,
196
+ markPending,
197
+ listPending,
198
+ mergeRemoteSessions,
199
+ };