aegiscode 6.3.2 → 6.5.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.
- package/README.md +77 -11
- package/bin/aegiscode.js +161 -2
- package/package.json +2 -2
- package/scripts/predist.mjs +5 -0
- package/src/app.js +178 -43
- package/src/cloudsync.js +401 -0
- package/src/commands.js +285 -17
- package/src/config.js +8 -6
- package/src/credentials.js +30 -0
- package/src/history.js +120 -8
- package/src/screens.js +159 -9
- package/src/secret.js +56 -0
- package/src/shared.js +45 -0
- package/vendor/client/credentials.js +386 -0
- package/vendor/client/session-store.js +418 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* credentials.js — the ONE AEGIS account credential store, shared by every
|
|
5
|
+
* host in this repo (terminal `aegiscode`, the MCP plugin, the desktop app).
|
|
6
|
+
*
|
|
7
|
+
* Why it lives in `client/`: this directory is the only tree all three hosts
|
|
8
|
+
* already bundle. The MCP plugin ships `mcp/` + `client/` and nothing else, so
|
|
9
|
+
* a reader placed here needs no cross-package dependency — which is what the
|
|
10
|
+
* alternative (the MCP host requiring `cli/src/credentials.js`) would have
|
|
11
|
+
* forced, and why that host used to read `AEGIS_API_KEY` from the environment
|
|
12
|
+
* alone while the CLI could save a key to disk. One login, one file, three
|
|
13
|
+
* hosts: that is the point of this module.
|
|
14
|
+
*
|
|
15
|
+
* Zero dependencies, no host imports. The data dir is resolved here
|
|
16
|
+
* (`aegisHome()`) rather than imported from the CLI's config.js, so this file
|
|
17
|
+
* runs from a bare `node client/credentials.js` inside the plugin tree.
|
|
18
|
+
*
|
|
19
|
+
* Resolution order, first hit wins:
|
|
20
|
+
*
|
|
21
|
+
* 1. `AEGIS_API_KEY` — the environment, so CI and an explicit export keep
|
|
22
|
+
* working and nothing written here can shadow them.
|
|
23
|
+
* 2. `credentials.json` in the data dir, mode 0600. The writable store.
|
|
24
|
+
* 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
|
|
25
|
+
* earlier AEGIS CLI left in the same data dir. Read, never written, and
|
|
26
|
+
* never deleted: it is another product's file and the user's key is in
|
|
27
|
+
* it. When one is found it is *also* copied into credentials.json so the
|
|
28
|
+
* next run reads the 0600 copy, and the status line says the plaintext
|
|
29
|
+
* copy is still there rather than silently leaving it.
|
|
30
|
+
*
|
|
31
|
+
* The key is never printed in full by anything in this repo; callers mask it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
const KEY_ENV = 'AEGIS_API_KEY';
|
|
39
|
+
/** Optional override for the memory token (cloud sync's own credential). */
|
|
40
|
+
const MEMORY_ENV = 'AEGIS_MEMORY_TOKEN';
|
|
41
|
+
const HOME_ENV = 'AEGISCODE_HOME';
|
|
42
|
+
const CREDENTIALS_FILE = 'credentials.json';
|
|
43
|
+
const CONFIG_FILE = 'config.json';
|
|
44
|
+
const FILE_MODE = 0o600;
|
|
45
|
+
const DIR_MODE = 0o700;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The per-user AEGIS data directory: `$AEGISCODE_HOME` or `~/.aegiscode`.
|
|
49
|
+
*
|
|
50
|
+
* Every host resolves its data dir through this one function, so the CLI, the
|
|
51
|
+
* MCP plugin and the desktop app agree on where memory and credentials live
|
|
52
|
+
* instead of each keeping a private store the others cannot see. (The desktop
|
|
53
|
+
* still keeps its *settings* — the safeStorage-encrypted key, window state —
|
|
54
|
+
* in Electron's userData; only the shared memory + credential files live here.)
|
|
55
|
+
*/
|
|
56
|
+
function aegisHome(env) {
|
|
57
|
+
const e = env || process.env;
|
|
58
|
+
const override = e && e[HOME_ENV];
|
|
59
|
+
if (override && String(override).trim()) return path.resolve(String(override).trim());
|
|
60
|
+
return path.join(os.homedir(), '.aegiscode');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function credentialsPath(dir) {
|
|
64
|
+
return path.join(dir || aegisHome(), CREDENTIALS_FILE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function configPath(dir) {
|
|
68
|
+
return path.join(dir || aegisHome(), CONFIG_FILE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Accept the key in the shapes a user actually pastes it.
|
|
73
|
+
*
|
|
74
|
+
* Copy-paste from a dashboard, a `.env` line, a shell profile or a chat message
|
|
75
|
+
* are all realistic — and pasting `AEGIS_API_KEY=aegis_…` or `"aegis_…"` into a
|
|
76
|
+
* prompt that stores the literal string produces an auth failure the user
|
|
77
|
+
* cannot see the cause of, because the key *looks* right in the status line.
|
|
78
|
+
*/
|
|
79
|
+
function normalizeApiKey(raw) {
|
|
80
|
+
let s = String(raw == null ? '' : raw).trim();
|
|
81
|
+
if (!s) return '';
|
|
82
|
+
s = s.replace(/^export\s+/i, '').trim();
|
|
83
|
+
const assignment = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/s.exec(s);
|
|
84
|
+
if (assignment) s = assignment[1].trim();
|
|
85
|
+
s = s.replace(/^["']|["']$/g, '').trim();
|
|
86
|
+
s = s.replace(/^Bearer\s+/i, '').trim();
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Shape check only — no network. Catches the two mistakes that are structural
|
|
92
|
+
* (an empty paste, and a wrapped/truncated multi-line paste) so the caller can
|
|
93
|
+
* refuse before spending a verification round trip.
|
|
94
|
+
*/
|
|
95
|
+
function validateApiKey(raw) {
|
|
96
|
+
const key = normalizeApiKey(raw);
|
|
97
|
+
if (!key) return { ok: false, key, reason: 'empty', message: 'no key given' };
|
|
98
|
+
if (/\s/.test(key)) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
key,
|
|
102
|
+
reason: 'whitespace',
|
|
103
|
+
message: 'that looks like more than one word — paste just the key',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (key.length < 16) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
key,
|
|
110
|
+
reason: 'too short',
|
|
111
|
+
message: `that key is ${key.length} characters — AEGIS keys are longer than that`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, key, reason: null, message: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The stored credential object, or {} — never throws on a missing/corrupt file. */
|
|
118
|
+
function readCredentials(dir) {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(fs.readFileSync(credentialsPath(dir), 'utf8'));
|
|
121
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
122
|
+
} catch {}
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merge a patch into the store, creating it 0600 (and tightening an existing
|
|
128
|
+
* file that is wider — a key file that a previous run left 0644 is exactly the
|
|
129
|
+
* leak this file exists to avoid).
|
|
130
|
+
*/
|
|
131
|
+
function writeCredentials(patch, dir) {
|
|
132
|
+
const target = credentialsPath(dir);
|
|
133
|
+
const next = { ...readCredentials(dir), ...patch, version: 1 };
|
|
134
|
+
try {
|
|
135
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: DIR_MODE });
|
|
136
|
+
const tmp = target + '.tmp';
|
|
137
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: FILE_MODE });
|
|
138
|
+
fs.renameSync(tmp, target);
|
|
139
|
+
try {
|
|
140
|
+
fs.chmodSync(target, FILE_MODE);
|
|
141
|
+
} catch {}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (process.env.AEGIS_HIST_DEBUG) console.error('[credentials] write failed:', e);
|
|
144
|
+
return { ok: false, error: e, credentials: next };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, credentials: next };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The key an earlier AEGIS CLI wrote into config.json (the `aegiscloud` /
|
|
151
|
+
* `memory` blocks are not ours). Read-only.
|
|
152
|
+
*/
|
|
153
|
+
function readLegacyConfig(dir) {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(fs.readFileSync(configPath(dir), 'utf8'));
|
|
156
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
157
|
+
const cloud = parsed.aegiscloud && typeof parsed.aegiscloud === 'object' ? parsed.aegiscloud : {};
|
|
158
|
+
const memory = parsed.memory && typeof parsed.memory === 'object' ? parsed.memory : {};
|
|
159
|
+
return {
|
|
160
|
+
apiKey: normalizeApiKey(cloud.api_key),
|
|
161
|
+
memoryToken: String(memory.token || '').trim(),
|
|
162
|
+
syncConversations: cloud.syncConversations === true ? true : undefined,
|
|
163
|
+
lastVerified: cloud.lastVerified || null,
|
|
164
|
+
memorySubscribed: memory.subscribed === true ? true : undefined,
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when config.json still carries a plaintext copy of the account key. */
|
|
172
|
+
function legacyKeyOnDisk(dir) {
|
|
173
|
+
return !!readLegacyConfig(dir).apiKey;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Copy a legacy key/token into the 0600 store, once, without touching the file
|
|
178
|
+
* it came from. Returns which fields were adopted so the caller can say so.
|
|
179
|
+
*/
|
|
180
|
+
function adoptLegacy(dir) {
|
|
181
|
+
const legacy = readLegacyConfig(dir);
|
|
182
|
+
const creds = readCredentials(dir);
|
|
183
|
+
const patch = {};
|
|
184
|
+
if (legacy.apiKey && !creds.aegisApiKey) patch.aegisApiKey = legacy.apiKey;
|
|
185
|
+
if (legacy.memoryToken && !creds.memoryToken) patch.memoryToken = legacy.memoryToken;
|
|
186
|
+
if (!Object.keys(patch).length) return { adopted: [] };
|
|
187
|
+
const written = writeCredentials({ ...patch, adoptedFrom: configPath(dir) }, dir);
|
|
188
|
+
if (!written.ok) return { adopted: [] };
|
|
189
|
+
return { adopted: Object.keys(patch) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The key to use, and where it came from.
|
|
194
|
+
*
|
|
195
|
+
* @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
|
|
196
|
+
*/
|
|
197
|
+
function resolveApiKey(o = {}) {
|
|
198
|
+
const env = o.env || process.env;
|
|
199
|
+
const dir = o.dir;
|
|
200
|
+
const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
|
|
201
|
+
if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
|
|
202
|
+
|
|
203
|
+
const creds = readCredentials(dir);
|
|
204
|
+
const stored = normalizeApiKey(creds.aegisApiKey);
|
|
205
|
+
if (stored) return { key: stored, source: 'credentials', from: credentialsPath(dir) };
|
|
206
|
+
|
|
207
|
+
const legacy = readLegacyConfig(dir).apiKey;
|
|
208
|
+
if (legacy) return { key: legacy, source: 'config', from: configPath(dir) };
|
|
209
|
+
|
|
210
|
+
return { key: '', source: 'none', from: '' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether any credential is available (env, store or legacy config). */
|
|
214
|
+
function hasApiKey(o = {}) {
|
|
215
|
+
return !!resolveApiKey(o).key;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Persist a key.
|
|
220
|
+
*
|
|
221
|
+
* @returns {{ok:boolean, key:string, path:string, error?:Error, message?:string}}
|
|
222
|
+
*/
|
|
223
|
+
function saveApiKey(raw, dir) {
|
|
224
|
+
const v = validateApiKey(raw);
|
|
225
|
+
if (!v.ok) return { ok: false, key: v.key, path: credentialsPath(dir), message: v.message };
|
|
226
|
+
const written = writeCredentials({ aegisApiKey: v.key, savedAt: new Date().toISOString() }, dir);
|
|
227
|
+
if (!written.ok) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
key: v.key,
|
|
231
|
+
path: credentialsPath(dir),
|
|
232
|
+
error: written.error,
|
|
233
|
+
message: `could not write ${credentialsPath(dir)}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, key: v.key, path: credentialsPath(dir) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Remove the stored key (and nothing else — the memory token stays, since a
|
|
241
|
+
* key rotation should not silently unsubscribe cloud memory). config.json is
|
|
242
|
+
* never touched: another product writes it.
|
|
243
|
+
*/
|
|
244
|
+
function clearApiKey(dir) {
|
|
245
|
+
const had = !!normalizeApiKey(readCredentials(dir).aegisApiKey);
|
|
246
|
+
const written = writeCredentials({ aegisApiKey: '', adoptedFrom: '' }, dir);
|
|
247
|
+
return { cleared: had, ok: written.ok, path: credentialsPath(dir) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The memory token cloud sync authenticates with, from store or legacy. */
|
|
251
|
+
function resolveMemoryToken(o = {}) {
|
|
252
|
+
const env = o.env || process.env;
|
|
253
|
+
const dir = o.dir;
|
|
254
|
+
const fromEnv = String((env && env[MEMORY_ENV]) || '').trim();
|
|
255
|
+
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
256
|
+
const creds = readCredentials(dir);
|
|
257
|
+
const stored = String(creds.memoryToken || '').trim();
|
|
258
|
+
if (stored) return { token: stored, source: 'credentials' };
|
|
259
|
+
const legacy = readLegacyConfig(dir).memoryToken;
|
|
260
|
+
if (legacy) return { token: legacy, source: 'config' };
|
|
261
|
+
return { token: '', source: 'none' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function saveMemoryToken(token, extra = {}, dir) {
|
|
265
|
+
return writeCredentials({ memoryToken: String(token || '').trim(), ...extra }, dir);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function clearMemoryToken(dir) {
|
|
269
|
+
return writeCredentials({ memoryToken: '', memorySubscribed: false }, dir);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Everything a status screen or a `doctor` line needs about the credential,
|
|
274
|
+
* without ever exposing the key itself.
|
|
275
|
+
*/
|
|
276
|
+
function keyStatus(o = {}) {
|
|
277
|
+
const dir = o.dir;
|
|
278
|
+
const { key, source, from } = resolveApiKey(o);
|
|
279
|
+
const creds = readCredentials(dir);
|
|
280
|
+
let mode = null;
|
|
281
|
+
try {
|
|
282
|
+
mode = fs.statSync(credentialsPath(dir)).mode & 0o777;
|
|
283
|
+
} catch {}
|
|
284
|
+
return {
|
|
285
|
+
configured: !!key,
|
|
286
|
+
key,
|
|
287
|
+
source,
|
|
288
|
+
from,
|
|
289
|
+
path: credentialsPath(dir),
|
|
290
|
+
fileMode: mode == null ? null : '0' + mode.toString(8),
|
|
291
|
+
stored: !!normalizeApiKey(creds.aegisApiKey),
|
|
292
|
+
legacyPlaintext: legacyKeyOnDisk(dir),
|
|
293
|
+
verifiedAt: creds.verifiedAt || null,
|
|
294
|
+
account: creds.account || null,
|
|
295
|
+
memoryToken: !!resolveMemoryToken(o).token,
|
|
296
|
+
memorySource: resolveMemoryToken(o).source,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Client options for `createClient`: the resolved key plus the stored memory
|
|
302
|
+
* token, so a restart does not re-exchange for a token it already has.
|
|
303
|
+
*/
|
|
304
|
+
function clientOptions(o = {}) {
|
|
305
|
+
const { key } = resolveApiKey(o);
|
|
306
|
+
const { token } = resolveMemoryToken(o);
|
|
307
|
+
const opts = {};
|
|
308
|
+
if (key) opts.apiKey = key;
|
|
309
|
+
if (token) opts.memoryToken = token;
|
|
310
|
+
return opts;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Human label for a resolution source, for status lines. */
|
|
314
|
+
const SOURCE_LABEL = {
|
|
315
|
+
env: `${KEY_ENV}`,
|
|
316
|
+
credentials: 'saved key file',
|
|
317
|
+
config: 'config.json (aegis CLI)',
|
|
318
|
+
none: 'not set',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
function sourceLabel(source) {
|
|
322
|
+
return SOURCE_LABEL[source] || SOURCE_LABEL.none;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Prefer the most recently saved key between a host's own copy and this file.
|
|
327
|
+
*
|
|
328
|
+
* The desktop keeps its key encrypted via Electron safeStorage, which is a
|
|
329
|
+
* better place for it than a 0600 file — but only one of the two can be in
|
|
330
|
+
* force, and picking wrong is silent: the user rotates their key with
|
|
331
|
+
* `aegiscode login`, the app keeps presenting an old string it saved last week,
|
|
332
|
+
* and every call 401s with a key that looks right in Settings.
|
|
333
|
+
*
|
|
334
|
+
* Both sides stamp `savedAt`, so the rule is simply "the newest write wins",
|
|
335
|
+
* with the host's own copy breaking a tie (it is encrypted). A missing stamp on
|
|
336
|
+
* either side loses to a present one; equal keys always resolve to the host's.
|
|
337
|
+
*
|
|
338
|
+
* @returns {{key:string, source:'host'|'shared'|'none', sharedAt:number, hostAt:number}}
|
|
339
|
+
*/
|
|
340
|
+
function preferNewest(storedKey, storedAt, o = {}) {
|
|
341
|
+
const ownKey = normalizeApiKey(storedKey);
|
|
342
|
+
const ownAt = Date.parse(storedAt || '') || 0;
|
|
343
|
+
const shared = readCredentials(o.dir);
|
|
344
|
+
const sharedKey = normalizeApiKey(shared.aegisApiKey);
|
|
345
|
+
const sharedAt = Date.parse(shared.savedAt || '') || 0;
|
|
346
|
+
|
|
347
|
+
if (!sharedKey) return { key: ownKey, source: ownKey ? 'host' : 'none', sharedAt, hostAt: ownAt };
|
|
348
|
+
if (!ownKey) return { key: sharedKey, source: 'shared', sharedAt, hostAt: ownAt };
|
|
349
|
+
if (ownKey === sharedKey) return { key: ownKey, source: 'host', sharedAt, hostAt: ownAt };
|
|
350
|
+
return sharedAt > ownAt
|
|
351
|
+
? { key: sharedKey, source: 'shared', sharedAt, hostAt: ownAt }
|
|
352
|
+
: { key: ownKey, source: 'host', sharedAt, hostAt: ownAt };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** One line telling the user how to supply a key, used by every error path. */
|
|
356
|
+
const HOW_TO_SET = 'run `aegiscode login` (or /key inside a session) to save one';
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
KEY_ENV,
|
|
360
|
+
MEMORY_ENV,
|
|
361
|
+
HOME_ENV,
|
|
362
|
+
CREDENTIALS_FILE,
|
|
363
|
+
CONFIG_FILE,
|
|
364
|
+
aegisHome,
|
|
365
|
+
credentialsPath,
|
|
366
|
+
configPath,
|
|
367
|
+
normalizeApiKey,
|
|
368
|
+
validateApiKey,
|
|
369
|
+
readCredentials,
|
|
370
|
+
writeCredentials,
|
|
371
|
+
readLegacyConfig,
|
|
372
|
+
legacyKeyOnDisk,
|
|
373
|
+
adoptLegacy,
|
|
374
|
+
resolveApiKey,
|
|
375
|
+
hasApiKey,
|
|
376
|
+
saveApiKey,
|
|
377
|
+
clearApiKey,
|
|
378
|
+
resolveMemoryToken,
|
|
379
|
+
saveMemoryToken,
|
|
380
|
+
clearMemoryToken,
|
|
381
|
+
keyStatus,
|
|
382
|
+
clientOptions,
|
|
383
|
+
preferNewest,
|
|
384
|
+
sourceLabel,
|
|
385
|
+
HOW_TO_SET,
|
|
386
|
+
};
|