aegis-desktop 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/vendor/authorship-key.js
CHANGED
|
@@ -1,412 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* authorship-key.js — the key half of φ(α): keygen, and where `--secret` comes
|
|
10
|
-
* from.
|
|
11
|
-
*
|
|
12
|
-
* `client/fingerprint.js` is the vendored engine module and answers "is this
|
|
13
|
-
* record intact?". With the *public* covenant hash as key, that answer is
|
|
14
|
-
* "intact, and by the way anyone holding the covenant can also forge it" —
|
|
15
|
-
* integrity, not authorship. This file is the other half of that sentence: a
|
|
16
|
-
* private key under the user's own directory, at mode 600, that no one else
|
|
17
|
-
* has, so a valid record means "minted by whoever holds this key".
|
|
18
|
-
*
|
|
19
|
-
* ## The ladder (`resolveKey`)
|
|
20
|
-
*
|
|
21
|
-
* 1. `--secret <file>` (alias `--secret-file`, the engine's name)
|
|
22
|
-
* 2. `AEGIS_FINGERPRINT_SECRET` in the environment
|
|
23
|
-
* 3. `$AEGIS_HOME/keys/authorship.key` — the same path `keygen` writes
|
|
24
|
-
* 4. nothing: refuse, and say which command mints one.
|
|
25
|
-
*
|
|
26
|
-
* Step 4 is where this deliberately *diverges* from the engine. The engine's
|
|
27
|
-
* last rung is the published covenant hash, which keeps its tools runnable with
|
|
28
|
-
* no setup at all. Here an unconfigured host gets an error instead, because the
|
|
29
|
-
* plugin's records are meant to travel — a plugin record that silently keyed
|
|
30
|
-
* itself to a public constant would verify on every covenant holder's machine,
|
|
31
|
-
* which reads as authorship to everyone who did not look up how it was keyed.
|
|
32
|
-
* A caller who genuinely wants the engine's public mode says so explicitly:
|
|
33
|
-
* `--public --covenant <file>`, and that mode is labelled on every record it
|
|
34
|
-
* mints (`keySource` in the tool output, never inside the HMAC body — see
|
|
35
|
-
* below).
|
|
36
|
-
*
|
|
37
|
-
* ## What never happens
|
|
38
|
-
*
|
|
39
|
-
* The raw secret is never printed, never returned in a plain-object field that
|
|
40
|
-
* a `console.log` would surface, and never written anywhere but the key file.
|
|
41
|
-
* `keygen` returns a *derived* fingerprint — `keyFingerprint(secret)` from the
|
|
42
|
-
* vendored module, i.e. `sha256('aegiscode-agent-v1\n' + secret)[0:12]`, the
|
|
43
|
-
* same 12 hex the avatar registry indexes by — so a human can confirm two
|
|
44
|
-
* machines hold the same key without either machine revealing it. The
|
|
45
|
-
* derivation is spelled out here because the engine's own comment calls it a
|
|
46
|
-
* bare `sha256(secret)`, which it is not; the code is the contract, and both
|
|
47
|
-
* host tests re-derive the formula rather than trusting either description.
|
|
48
|
-
*
|
|
49
|
-
* Mode 600 is enforced twice on purpose: `fs.writeFileSync`'s `mode` is masked
|
|
50
|
-
* by the process umask, so passing it is not the same as getting it. The write
|
|
51
|
-
* is followed by an explicit `chmodSync` and a re-`stat` that throws if the
|
|
52
|
-
* result is not 0600 — a key that lands 644 must fail the operation, not warn
|
|
53
|
-
* about it. Both `resolveKey` and `backup` refuse a file that is group- or
|
|
54
|
-
* other-readable (any bit of 0o077); a secret the filesystem will hand to
|
|
55
|
-
* another user is not a secret. On Windows there is no POSIX mode to check, so
|
|
56
|
-
* the guarantee there is the directory (0700 under the user's profile) rather
|
|
57
|
-
* than the file — stated here because the tests can only assert the POSIX half.
|
|
58
|
-
*
|
|
59
|
-
* No privilege is involved anywhere: `node:fs`/`node:crypto` under `$HOME`, and
|
|
60
|
-
* one `git check-ignore` spawn in `backup` that is bounded and failure-tolerant.
|
|
61
|
-
*/
|
|
62
|
-
|
|
63
|
-
const crypto = require('node:crypto');
|
|
64
|
-
const fs = require('node:fs');
|
|
65
|
-
const os = require('node:os');
|
|
66
|
-
const path = require('node:path');
|
|
67
|
-
const { execFileSync } = require('node:child_process');
|
|
68
|
-
|
|
69
|
-
const fp = require('./fingerprint.js');
|
|
70
|
-
|
|
71
|
-
/** 0600 — owner read/write only. Enforced after the write, and re-checked. */
|
|
72
|
-
const KEY_FILE_MODE = 0o600;
|
|
73
|
-
const DIR_MODE = 0o700;
|
|
74
|
-
|
|
75
|
-
/** Any of these bits set means the file is readable by someone else. */
|
|
76
|
-
const GROUP_OTHER_BITS = 0o077;
|
|
77
|
-
|
|
78
|
-
/** 32 random bytes, base64url: 256 bits, matching the engine's keygen exactly. */
|
|
79
|
-
const KEY_BYTES = 32;
|
|
80
|
-
|
|
81
|
-
const SECRET_ENV = 'AEGIS_FINGERPRINT_SECRET';
|
|
82
|
-
const HOME_ENV = 'AEGIS_HOME';
|
|
83
|
-
const KEY_FILENAME = 'authorship.key';
|
|
84
|
-
|
|
85
|
-
const isWin = process.platform === 'win32';
|
|
86
|
-
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// paths
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
/** `$AEGIS_HOME`, else `~/.aegiscode` — the plugin's private store. */
|
|
92
|
-
function dataDir(env = process.env) {
|
|
93
|
-
const home = env[HOME_ENV];
|
|
94
|
-
if (home && String(home).trim()) return path.resolve(String(home).trim());
|
|
95
|
-
return path.join(os.homedir(), '.aegiscode');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Where `keygen` writes with no `--out`, and the ladder's third rung. */
|
|
99
|
-
function defaultKeyPath(env = process.env) {
|
|
100
|
-
return path.join(dataDir(env), 'keys', KEY_FILENAME);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function modeOf(file) {
|
|
104
|
-
return fs.statSync(file).mode & 0o777;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function modeString(mode) {
|
|
108
|
-
return mode.toString(8).padStart(3, '0');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Refuse a secret file that other users can read.
|
|
113
|
-
*
|
|
114
|
-
* Not a warning: `resolveKey` has already chosen this file, so continuing means
|
|
115
|
-
* minting records under a key the filesystem publishes. The message names the
|
|
116
|
-
* one-line fix, because the alternative fix a user reaches for first — deleting
|
|
117
|
-
* the key and starting over — orphans every record already minted under it.
|
|
118
|
-
*/
|
|
119
|
-
function assertPrivate(file) {
|
|
120
|
-
if (isWin) return; // no POSIX mode; the directory is the boundary (see header)
|
|
121
|
-
const mode = modeOf(file);
|
|
122
|
-
if (mode & GROUP_OTHER_BITS) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
`authorship-key: ${file} is mode ${modeString(mode)} — a secret file must not be ` +
|
|
125
|
-
`group/other readable. chmod 600 ${file}`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** A key file is one line of base64url. Anything else is not one. */
|
|
131
|
-
function readSecretFile(file) {
|
|
132
|
-
const raw = fs.readFileSync(file, 'utf8');
|
|
133
|
-
const secret = raw.replace(/\r?\n$/, '');
|
|
134
|
-
if (!secret.trim()) throw new Error(`authorship-key: ${file} is empty`);
|
|
135
|
-
if (/\s/.test(secret.trim())) {
|
|
136
|
-
throw new Error(`authorship-key: ${file} holds more than one line — not a key file`);
|
|
137
|
-
}
|
|
138
|
-
return secret.trim();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
// argv
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* `--secret` and `--secret-file` are the same flag. The engine spells it
|
|
147
|
-
* `--secret-file`; the plugin's docs and the request that produced this module
|
|
148
|
-
* both say `--secret`. Accepting one name and rejecting the other would be a
|
|
149
|
-
* parse error where a user means the same thing, so both are read here — once,
|
|
150
|
-
* in the shared module, so the CLI and the desktop cannot disagree about it.
|
|
151
|
-
*/
|
|
152
|
-
function flag(argv, name, fallback = null) {
|
|
153
|
-
const names = name === '--secret' ? ['--secret', '--secret-file'] : [name];
|
|
154
|
-
for (const n of names) {
|
|
155
|
-
const i = argv.indexOf(n);
|
|
156
|
-
if (i !== -1 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) return argv[i + 1];
|
|
157
|
-
}
|
|
158
|
-
return fallback;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function hasFlag(argv, name) {
|
|
162
|
-
const names = name === '--secret' ? ['--secret', '--secret-file'] : [name];
|
|
163
|
-
return names.some((n) => argv.includes(n));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// the ladder
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Resolve the key to mint/verify with.
|
|
172
|
-
*
|
|
173
|
-
* @returns {{secret: string|null, source: string, public: boolean, path: string|null}}
|
|
174
|
-
* `secret: null` means "no key configured" — the caller decides whether
|
|
175
|
-
* that is a refusal (mint/verify) or an ordinary unconfigured state
|
|
176
|
-
* (status), exactly as presence does with a missing API key.
|
|
177
|
-
*/
|
|
178
|
-
function resolveKey(o = {}) {
|
|
179
|
-
const argv = o.argv || [];
|
|
180
|
-
const env = o.env || process.env;
|
|
181
|
-
|
|
182
|
-
const file = flag(argv, '--secret');
|
|
183
|
-
if (file) {
|
|
184
|
-
if (!fs.existsSync(file)) throw new Error(`authorship-key: no such secret file: ${file}`);
|
|
185
|
-
assertPrivate(file);
|
|
186
|
-
return { secret: readSecretFile(file), source: file, public: false, path: file };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (hasFlag(argv, '--public')) {
|
|
190
|
-
// Explicit public mode, and only with the document that defines the key.
|
|
191
|
-
const covenant = flag(argv, '--covenant');
|
|
192
|
-
if (!covenant) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
'authorship-key: --public requires --covenant <file> — the public key IS the ' +
|
|
195
|
-
"covenant document's hash, so it cannot be assumed",
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
if (!fs.existsSync(covenant)) {
|
|
199
|
-
throw new Error(`authorship-key: no such covenant file: ${covenant}`);
|
|
200
|
-
}
|
|
201
|
-
const secret = crypto.createHash('sha256').update(fs.readFileSync(covenant)).digest('hex');
|
|
202
|
-
return { secret, source: `covenant (public): ${covenant}`, public: true, path: null };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const envSecret = env[SECRET_ENV];
|
|
206
|
-
if (envSecret && String(envSecret).trim()) {
|
|
207
|
-
return { secret: String(envSecret).trim(), source: SECRET_ENV, public: false, path: null };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const def = defaultKeyPath(env);
|
|
211
|
-
if (fs.existsSync(def)) {
|
|
212
|
-
assertPrivate(def);
|
|
213
|
-
return { secret: readSecretFile(def), source: def, public: false, path: def };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return { secret: null, source: null, public: false, path: null };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
|
-
// keygen
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Mint a private key.
|
|
225
|
-
*
|
|
226
|
-
* `--out F` chooses the path (default `$AEGIS_HOME/keys/authorship.key`);
|
|
227
|
-
* `--force` overwrites an existing one. Without `--force` an existing key is an
|
|
228
|
-
* error, never a silent replacement: overwriting the key orphans every record
|
|
229
|
-
* ever minted under it, and the loss is invisible until someone verifies an old
|
|
230
|
-
* record. Replacing a key is a deliberate act, so it is a flag.
|
|
231
|
-
*
|
|
232
|
-
* @returns {{out: string, created: boolean, replaced: boolean,
|
|
233
|
-
* keyFingerprint: string, bytes: number, secretPrinted: false, mode: string}}
|
|
234
|
-
*/
|
|
235
|
-
/**
|
|
236
|
-
* Write a secret to `file`, creating its parent directory, and translate the
|
|
237
|
-
* filesystem errnos a user actually hits into a sentence that names the fix.
|
|
238
|
-
*
|
|
239
|
-
* `keygen` is the first command a new install runs, and its failure mode on a
|
|
240
|
-
* locked-down or full disk used to be the bare kernel message — `EACCES:
|
|
241
|
-
* permission denied, open '/home/u/.aegiscode/keys/authorship.key'`. That says
|
|
242
|
-
* what broke and not what to do, and the remedy is not guessable from it: the
|
|
243
|
-
* unwritable thing is the *directory*, which the path in the message never
|
|
244
|
-
* names. Found by the `unwritable-home` usertest profile, which reported it
|
|
245
|
-
* three times across as many commits before it was treated as a defect.
|
|
246
|
-
*
|
|
247
|
-
* Only the three errnos with an unambiguous remedy are rewritten. Everything
|
|
248
|
-
* else propagates untouched — inventing advice for an errno we do not recognise
|
|
249
|
-
* would be worse than the errno.
|
|
250
|
-
*/
|
|
251
|
-
function writeSecret(file, secret) {
|
|
252
|
-
try {
|
|
253
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
|
|
254
|
-
fs.writeFileSync(file, secret + '\n', { mode: KEY_FILE_MODE });
|
|
255
|
-
} catch (e) {
|
|
256
|
-
const errno = e && e.code;
|
|
257
|
-
const dir = path.dirname(file);
|
|
258
|
-
if (errno === 'EACCES' || errno === 'EPERM') {
|
|
259
|
-
throw new Error(
|
|
260
|
-
`authorship-key: cannot write ${file} — ${dir} is not writable by this user (${errno}). ` +
|
|
261
|
-
`Fix it with: chmod u+rwx ${dir}`,
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
if (errno === 'EROFS') {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`authorship-key: cannot write ${file} — ${dir} is on a read-only filesystem (EROFS). ` +
|
|
267
|
-
'Point --out (or $AEGIS_HOME) at a writable location.',
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
if (errno === 'ENOSPC') {
|
|
271
|
-
throw new Error(`authorship-key: cannot write ${file} — the disk is full (ENOSPC).`);
|
|
272
|
-
}
|
|
273
|
-
throw e;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function keygen(o = {}) {
|
|
278
|
-
const argv = o.argv || [];
|
|
279
|
-
const env = o.env || process.env;
|
|
280
|
-
const out = path.resolve(flag(argv, '--out') || defaultKeyPath(env));
|
|
281
|
-
const force = hasFlag(argv, '--force') || o.force === true;
|
|
282
|
-
|
|
283
|
-
const exists = fs.existsSync(out);
|
|
284
|
-
if (exists && !force) {
|
|
285
|
-
throw new Error(`authorship-key: ${out} already exists — pass --force to replace it`);
|
|
286
|
-
}
|
|
287
|
-
if (exists && !isWin) {
|
|
288
|
-
// Replacing a key that is itself world-readable is how a *second* party
|
|
289
|
-
// ends up holding the old one; refuse rather than rotate on top of it.
|
|
290
|
-
assertPrivate(out);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const secret = crypto.randomBytes(KEY_BYTES).toString('base64url');
|
|
294
|
-
writeSecret(out, secret);
|
|
295
|
-
// writeFileSync's mode is ANDed with the complement of the umask, so a 0022
|
|
296
|
-
// umask turns 0600 into 0644 and the call still "succeeds". Set it outright.
|
|
297
|
-
fs.chmodSync(out, KEY_FILE_MODE);
|
|
298
|
-
if (!isWin) {
|
|
299
|
-
const mode = modeOf(out);
|
|
300
|
-
if (mode !== KEY_FILE_MODE) {
|
|
301
|
-
throw new Error(
|
|
302
|
-
`authorship-key: ${out} landed mode ${modeString(mode)}, expected 600 — refusing to ` +
|
|
303
|
-
'report success for a key another user may be able to read',
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Read it back: a key that does not survive the round trip is worse than no
|
|
309
|
-
// key, because the fingerprint below would name something unrecoverable.
|
|
310
|
-
const readBack = readSecretFile(out);
|
|
311
|
-
if (readBack !== secret) throw new Error(`authorship-key: ${out} does not read back as written`);
|
|
312
|
-
|
|
313
|
-
return {
|
|
314
|
-
out,
|
|
315
|
-
created: !exists,
|
|
316
|
-
replaced: exists,
|
|
317
|
-
keyFingerprint: fp.keyFingerprint(secret),
|
|
318
|
-
bytes: KEY_BYTES,
|
|
319
|
-
secretPrinted: false,
|
|
320
|
-
mode: isWin ? 'n/a' : modeString(modeOf(out)),
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ---------------------------------------------------------------------------
|
|
325
|
-
// backup
|
|
326
|
-
// ---------------------------------------------------------------------------
|
|
327
|
-
|
|
328
|
-
function git(args, cwd) {
|
|
329
|
-
try {
|
|
330
|
-
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
331
|
-
} catch {
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function insideGitTree(dir) {
|
|
337
|
-
return git(['rev-parse', '--is-inside-work-tree'], dir) === 'true\n';
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Copy the key to a destination that is not inside a git working tree, unless
|
|
342
|
-
* the destination is covered by that repo's `.gitignore`.
|
|
343
|
-
*
|
|
344
|
-
* The check exists because the failure it prevents is unrecoverable in the
|
|
345
|
-
* wrong direction: a key committed once is public forever, and rotating it does
|
|
346
|
-
* not un-publish it. `--allow-git` is the escape hatch for a user who has
|
|
347
|
-
* deliberately encrypted or otherwise protected the destination.
|
|
348
|
-
*/
|
|
349
|
-
function backup(o = {}) {
|
|
350
|
-
const argv = o.argv || [];
|
|
351
|
-
const env = o.env || process.env;
|
|
352
|
-
const to = flag(argv, '--to');
|
|
353
|
-
if (!to) throw new Error('authorship-key: backup requires --to DIR');
|
|
354
|
-
|
|
355
|
-
const from = flag(argv, '--secret') || defaultKeyPath(env);
|
|
356
|
-
if (!fs.existsSync(from)) throw new Error(`authorship-key: no key at ${from} — run keygen first`);
|
|
357
|
-
assertPrivate(from);
|
|
358
|
-
|
|
359
|
-
const destDir = path.resolve(to);
|
|
360
|
-
|
|
361
|
-
const name = flag(argv, '--name') || path.basename(from);
|
|
362
|
-
const dest = path.join(destDir, name);
|
|
363
|
-
|
|
364
|
-
if (insideGitTree(destDir) && !hasFlag(argv, '--allow-git') && !o.allowGit) {
|
|
365
|
-
const ignored = git(['check-ignore', '-q', dest], destDir);
|
|
366
|
-
if (ignored === null) {
|
|
367
|
-
throw new Error(
|
|
368
|
-
`authorship-key: ${dest} is inside a git working tree and is not gitignored — ` +
|
|
369
|
-
'a committed key cannot be un-published. Add it to .gitignore, choose another ' +
|
|
370
|
-
'--to, or pass --allow-git if you know the destination is protected',
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const secret = readSecretFile(from);
|
|
376
|
-
writeSecret(dest, secret);
|
|
377
|
-
fs.chmodSync(dest, KEY_FILE_MODE);
|
|
378
|
-
if (!isWin && modeOf(dest) !== KEY_FILE_MODE) {
|
|
379
|
-
throw new Error(`authorship-key: ${dest} landed mode ${modeString(modeOf(dest))}, expected 600`);
|
|
380
|
-
}
|
|
381
|
-
if (readSecretFile(dest) !== secret) {
|
|
382
|
-
throw new Error(`authorship-key: ${dest} does not read back as the same key`);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return {
|
|
386
|
-
from,
|
|
387
|
-
to: dest,
|
|
388
|
-
keyFingerprint: fp.keyFingerprint(secret),
|
|
389
|
-
matchesSource: true,
|
|
390
|
-
secretPrinted: false,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
module.exports = {
|
|
395
|
-
KEY_FILE_MODE,
|
|
396
|
-
DIR_MODE,
|
|
397
|
-
GROUP_OTHER_BITS,
|
|
398
|
-
KEY_BYTES,
|
|
399
|
-
SECRET_ENV,
|
|
400
|
-
HOME_ENV,
|
|
401
|
-
KEY_FILENAME,
|
|
402
|
-
dataDir,
|
|
403
|
-
defaultKeyPath,
|
|
404
|
-
assertPrivate,
|
|
405
|
-
readSecretFile,
|
|
406
|
-
flag,
|
|
407
|
-
hasFlag,
|
|
408
|
-
resolveKey,
|
|
409
|
-
keygen,
|
|
410
|
-
backup,
|
|
411
|
-
modeString,
|
|
412
|
-
};
|
|
3
|
+
"use strict";const crypto=require("node:crypto"),fs=require("node:fs"),os=require("node:os"),path=require("node:path"),{execFileSync:execFileSync}=require("node:child_process"),fp=require("./fingerprint.js"),KEY_FILE_MODE=384,DIR_MODE=448,GROUP_OTHER_BITS=63,KEY_BYTES=32,SECRET_ENV="AEGIS_FINGERPRINT_SECRET",HOME_ENV="AEGIS_HOME",KEY_FILENAME="authorship.key",isWin="win32"===process.platform;function dataDir(e=process.env){const r=e[HOME_ENV];return r&&String(r).trim()?path.resolve(String(r).trim()):path.join(os.homedir(),".aegiscode")}function defaultKeyPath(e=process.env){return path.join(dataDir(e),"keys",KEY_FILENAME)}function modeOf(e){return 511&fs.statSync(e).mode}function modeString(e){return e.toString(8).padStart(3,"0")}function assertPrivate(e){if(isWin)return;const r=modeOf(e);if(r&GROUP_OTHER_BITS)throw new Error(`authorship-key: ${e} is mode ${modeString(r)} — a secret file must not be group/other readable. chmod 600 ${e}`)}function readSecretFile(e){const r=fs.readFileSync(e,"utf8").replace(/\r?\n$/,"");if(!r.trim())throw new Error(`authorship-key: ${e} is empty`);if(/\s/.test(r.trim()))throw new Error(`authorship-key: ${e} holds more than one line — not a key file`);return r.trim()}function flag(e,r,t=null){const i="--secret"===r?["--secret","--secret-file"]:[r];for(const r of i){const t=e.indexOf(r);if(-1!==t&&void 0!==e[t+1]&&!e[t+1].startsWith("--"))return e[t+1]}return t}function hasFlag(e,r){return("--secret"===r?["--secret","--secret-file"]:[r]).some((r=>e.includes(r)))}function resolveKey(e={}){const r=e.argv||[],t=e.env||process.env,i=flag(r,"--secret");if(i){if(!fs.existsSync(i))throw new Error(`authorship-key: no such secret file: ${i}`);return assertPrivate(i),{secret:readSecretFile(i),source:i,public:!1,path:i}}if(hasFlag(r,"--public")){const e=flag(r,"--covenant");if(!e)throw new Error("authorship-key: --public requires --covenant <file> — the public key IS the covenant document's hash, so it cannot be assumed");if(!fs.existsSync(e))throw new Error(`authorship-key: no such covenant file: ${e}`);return{secret:crypto.createHash("sha256").update(fs.readFileSync(e)).digest("hex"),source:`covenant (public): ${e}`,public:!0,path:null}}const o=t[SECRET_ENV];if(o&&String(o).trim())return{secret:String(o).trim(),source:SECRET_ENV,public:!1,path:null};const n=defaultKeyPath(t);return fs.existsSync(n)?(assertPrivate(n),{secret:readSecretFile(n),source:n,public:!1,path:n}):{secret:null,source:null,public:!1,path:null}}function writeSecret(e,r){try{fs.mkdirSync(path.dirname(e),{recursive:!0,mode:DIR_MODE}),fs.writeFileSync(e,r+"\n",{mode:KEY_FILE_MODE})}catch(r){const t=r&&r.code,i=path.dirname(e);if("EACCES"===t||"EPERM"===t)throw new Error(`authorship-key: cannot write ${e} — ${i} is not writable by this user (${t}). Fix it with: chmod u+rwx ${i}`);if("EROFS"===t)throw new Error(`authorship-key: cannot write ${e} — ${i} is on a read-only filesystem (EROFS). Point --out (or $AEGIS_HOME) at a writable location.`);if("ENOSPC"===t)throw new Error(`authorship-key: cannot write ${e} — the disk is full (ENOSPC).`);throw r}}function keygen(e={}){const r=e.argv||[],t=e.env||process.env,i=path.resolve(flag(r,"--out")||defaultKeyPath(t)),o=hasFlag(r,"--force")||!0===e.force,n=fs.existsSync(i);if(n&&!o)throw new Error(`authorship-key: ${i} already exists — pass --force to replace it`);n&&!isWin&&assertPrivate(i);const s=crypto.randomBytes(32).toString("base64url");if(writeSecret(i,s),fs.chmodSync(i,KEY_FILE_MODE),!isWin){const e=modeOf(i);if(e!==KEY_FILE_MODE)throw new Error(`authorship-key: ${i} landed mode ${modeString(e)}, expected 600 — refusing to report success for a key another user may be able to read`)}if(readSecretFile(i)!==s)throw new Error(`authorship-key: ${i} does not read back as written`);return{out:i,created:!n,replaced:n,keyFingerprint:fp.keyFingerprint(s),bytes:32,secretPrinted:!1,mode:isWin?"n/a":modeString(modeOf(i))}}function git(e,r){try{return execFileSync("git",e,{cwd:r,encoding:"utf8",stdio:["ignore","pipe","ignore"]})}catch{return null}}function insideGitTree(e){return"true\n"===git(["rev-parse","--is-inside-work-tree"],e)}function backup(e={}){const r=e.argv||[],t=e.env||process.env,i=flag(r,"--to");if(!i)throw new Error("authorship-key: backup requires --to DIR");const o=flag(r,"--secret")||defaultKeyPath(t);if(!fs.existsSync(o))throw new Error(`authorship-key: no key at ${o} — run keygen first`);assertPrivate(o);const n=path.resolve(i),s=flag(r,"--name")||path.basename(o),a=path.join(n,s);if(insideGitTree(n)&&!hasFlag(r,"--allow-git")&&!e.allowGit&&null===git(["check-ignore","-q",a],n))throw new Error(`authorship-key: ${a} is inside a git working tree and is not gitignored — a committed key cannot be un-published. Add it to .gitignore, choose another --to, or pass --allow-git if you know the destination is protected`);const c=readSecretFile(o);if(writeSecret(a,c),fs.chmodSync(a,KEY_FILE_MODE),!isWin&&modeOf(a)!==KEY_FILE_MODE)throw new Error(`authorship-key: ${a} landed mode ${modeString(modeOf(a))}, expected 600`);if(readSecretFile(a)!==c)throw new Error(`authorship-key: ${a} does not read back as the same key`);return{from:o,to:a,keyFingerprint:fp.keyFingerprint(c),matchesSource:!0,secretPrinted:!1}}module.exports={KEY_FILE_MODE:KEY_FILE_MODE,DIR_MODE:DIR_MODE,GROUP_OTHER_BITS:GROUP_OTHER_BITS,KEY_BYTES:32,SECRET_ENV:SECRET_ENV,HOME_ENV:HOME_ENV,KEY_FILENAME:KEY_FILENAME,dataDir:dataDir,defaultKeyPath:defaultKeyPath,assertPrivate:assertPrivate,readSecretFile:readSecretFile,flag:flag,hasFlag:hasFlag,resolveKey:resolveKey,keygen:keygen,backup:backup,modeString:modeString};
|
package/vendor/auto-provision.js
CHANGED
|
@@ -1,136 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Auto-provision a free aegis1 device key when a host finds no key configured
|
|
10
|
-
* anywhere (env, the shared 0600 store, legacy config.json). One
|
|
11
|
-
* implementation shared by every host (MCP plugin, desktop, CLI) — see
|
|
12
|
-
* client/credentials.js's own header for why that matters here: three copies
|
|
13
|
-
* of the same timeout/endpoint/save logic is how they drift.
|
|
14
|
-
*
|
|
15
|
-
* Calls aegis1's `POST /api/v1/auth/register-device` (see client/aegis.js's
|
|
16
|
-
* `registerDevice`), idempotent per machine fingerprint (client/
|
|
17
|
-
* device-fingerprint.js) so a wiped credential store still resolves back to
|
|
18
|
-
* the same account rather than minting a new, unfunded one.
|
|
19
|
-
*
|
|
20
|
-
* As of aegis1 commit 4372830, a brand-new fingerprint (first call ever for
|
|
21
|
-
* this machine) is granted the 30-day welcome-trial `unlimited` entitlement
|
|
22
|
-
* server-side — a token-bank credit (UNLIMITED_CLOUD_ALLOWANCE_MICROS) and a
|
|
23
|
-
* time-boxed `unlimited` row in `subscriptions` — in the SAME request that
|
|
24
|
-
* mints the key, not a separate step this client has to ask for. is_pro()'s
|
|
25
|
-
* pool gate and the BYOK fee waiver both see that grant immediately. This
|
|
26
|
-
* comment previously said the account "starts at ZERO balance" and "only
|
|
27
|
-
* unblocks BYOK" — that described pre-4372830 behavior and was stale; see
|
|
28
|
-
* services/billing_service.py's is_pro()/is_unlimited() docstrings on the
|
|
29
|
-
* aegis1 side for the current contract. A re-registration of an already-known
|
|
30
|
-
* fingerprint (the idempotent path) does NOT re-grant the trial — it only
|
|
31
|
-
* happens once per machine, ever.
|
|
32
|
-
*/
|
|
33
|
-
const { machineFingerprint } = require('./device-fingerprint.js');
|
|
34
|
-
|
|
35
|
-
const DEVICE_REGISTER_TIMEOUT_MS = 8000;
|
|
36
|
-
const DEVICE_REGISTER_RETRY_DELAYS_MS = [1000, 3000];
|
|
37
|
-
|
|
38
|
-
// Network failure, timeout (status 0), 408, or 5xx. A 4xx — including 429, the
|
|
39
|
-
// server's per-IP registration cap — is a refusal, and retrying it only spends
|
|
40
|
-
// more of that cap.
|
|
41
|
-
function isTransientRegisterError(err) {
|
|
42
|
-
const status = err && err.status;
|
|
43
|
-
return !status || status === 408 || status >= 500;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Why automatic registration gave up, in words a host can show the user.
|
|
47
|
-
function provisionFailureText(result) {
|
|
48
|
-
const err = result && result.error;
|
|
49
|
-
if (!err) return 'the server returned no account key';
|
|
50
|
-
if (err.status === 429) return 'too many sign-ups from this network — try again later';
|
|
51
|
-
if (err.status >= 500) return `aegiscloud.org failed to register this device: ${err.message}`;
|
|
52
|
-
if (isTransientRegisterError(err)) return 'could not reach aegiscloud.org';
|
|
53
|
-
return `the server refused the request (HTTP ${err.status})`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// What to say about the welcome trial after a successful registration — or ''
|
|
57
|
-
// when there is nothing worth saying. Shared by every host so the CLI's two
|
|
58
|
-
// notice sites and the desktop cannot describe the same outcome in three
|
|
59
|
-
// different ways, which is how the reused case went unmentioned for so long.
|
|
60
|
-
//
|
|
61
|
-
// The two clauses are mutually exclusive in practice (the server only sends
|
|
62
|
-
// `trial_granted: false` on the new-fingerprint path, and `reused_account` only
|
|
63
|
-
// on the idempotent one) but `reusedAccount` is checked first: if they ever
|
|
64
|
-
// collide, "you already have this account" is the true statement, and a
|
|
65
|
-
// throttling claim would be a lie about a machine that was never throttled.
|
|
66
|
-
function trialOutcomeNote(result) {
|
|
67
|
-
if (result && result.reusedAccount) {
|
|
68
|
-
return '. This machine was already registered, so it got its existing account back — the welcome trial is granted once per machine, ever, and was not granted again. A fresh trial needs a new sign-up, not a new key.';
|
|
69
|
-
}
|
|
70
|
-
if (result && result.trialGranted === false) {
|
|
71
|
-
return '. The welcome trial was withheld this hour (too many new devices from this network)';
|
|
72
|
-
}
|
|
73
|
-
return '';
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* @param {object} aegisClient A client from client/aegis.js (createClient()).
|
|
78
|
-
* @param {object} credentials client/credentials.js (or a compatible double).
|
|
79
|
-
* @param {{app?: string, retryDelaysMs?: number[]}} [options] `app` names the host
|
|
80
|
-
* for the server's presence panel; `retryDelaysMs` waits before each retry of a
|
|
81
|
-
* transient failure.
|
|
82
|
-
* @returns {Promise<{ok: boolean, key: string, trialGranted?: boolean, reusedAccount?: boolean, error?: Error}>}
|
|
83
|
-
* `ok` is true only when a usable key was obtained AND persisted to the
|
|
84
|
-
* shared store; `aegisClient.setApiKey()` has already been called on
|
|
85
|
-
* success, so the caller's client is immediately usable either way it
|
|
86
|
-
* inspects `ok`. `trialGranted` is false when the server withheld the
|
|
87
|
-
* welcome trial (the account and key are still valid).
|
|
88
|
-
*
|
|
89
|
-
* `reusedAccount` is the third outcome, and the one that used to be
|
|
90
|
-
* indistinguishable from a plain first install: this fingerprint was already
|
|
91
|
-
* registered, so aegis1 handed back the SAME account (aegis1 commit 2e2bd85
|
|
92
|
-
* added the `reused_account` field for exactly this). No trial is granted on
|
|
93
|
-
* that path and none can be — the welcome credit is once per machine, ever —
|
|
94
|
-
* and the server deliberately OMITS `trial_granted` there rather than
|
|
95
|
-
* sending false, so `trialGranted` stays `undefined`. Hosts must therefore
|
|
96
|
-
* branch on `reusedAccount`, not on `trialGranted`: reading an absent
|
|
97
|
-
* `trial_granted` as "not reused" is what left a wiped credential store
|
|
98
|
-
* showing a first-install notice over a returning account.
|
|
99
|
-
*/
|
|
100
|
-
async function autoProvisionApiKey(aegisClient, credentials, options = {}) {
|
|
101
|
-
if (Array.isArray(options)) {
|
|
102
|
-
throw new TypeError('autoProvisionApiKey takes { app, retryDelaysMs }; a bare retry array is no longer accepted');
|
|
103
|
-
}
|
|
104
|
-
if (aegisClient.apiKey) return { ok: true, key: aegisClient.apiKey };
|
|
105
|
-
const { app, retryDelaysMs = DEVICE_REGISTER_RETRY_DELAYS_MS } = options;
|
|
106
|
-
let lastError = null;
|
|
107
|
-
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
|
108
|
-
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt - 1]));
|
|
109
|
-
const controller = new AbortController();
|
|
110
|
-
const timer = setTimeout(() => controller.abort(), DEVICE_REGISTER_TIMEOUT_MS);
|
|
111
|
-
try {
|
|
112
|
-
const data = await aegisClient.registerDevice(machineFingerprint(), { app, signal: controller.signal });
|
|
113
|
-
const key = data && typeof data.api_key === 'string' ? data.api_key.trim() : '';
|
|
114
|
-
if (!key) return { ok: false, key: '' };
|
|
115
|
-
const saved = credentials.saveApiKey(key);
|
|
116
|
-
aegisClient.setApiKey(key);
|
|
117
|
-
// `=== true` on purpose: an older aegis1 that predates `reused_account`
|
|
118
|
-
// sends nothing, and unknown must not read as reused.
|
|
119
|
-
return { ok: !!saved.ok, key, trialGranted: data.trial_granted, reusedAccount: data.reused_account === true };
|
|
120
|
-
} catch (err) {
|
|
121
|
-
lastError = err;
|
|
122
|
-
if (!isTransientRegisterError(err)) break;
|
|
123
|
-
} finally {
|
|
124
|
-
clearTimeout(timer);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return { ok: false, key: '', error: lastError };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
module.exports = {
|
|
131
|
-
autoProvisionApiKey,
|
|
132
|
-
provisionFailureText,
|
|
133
|
-
trialOutcomeNote,
|
|
134
|
-
DEVICE_REGISTER_TIMEOUT_MS,
|
|
135
|
-
DEVICE_REGISTER_RETRY_DELAYS_MS,
|
|
136
|
-
};
|
|
3
|
+
"use strict";const{machineFingerprint:machineFingerprint}=require("./device-fingerprint.js"),DEVICE_REGISTER_TIMEOUT_MS=8e3,DEVICE_REGISTER_RETRY_DELAYS_MS=[1e3,3e3];function isTransientRegisterError(e){const r=e&&e.status;return!r||408===r||r>=500}function provisionFailureText(e){const r=e&&e.error;return r?429===r.status?"too many sign-ups from this network — try again later":r.status>=500?`aegiscloud.org failed to register this device: ${r.message}`:isTransientRegisterError(r)?"could not reach aegiscloud.org":`the server refused the request (HTTP ${r.status})`:"the server returned no account key"}function trialOutcomeNote(e){return e&&e.reusedAccount?". This machine was already registered, so it got its existing account back — the welcome trial is granted once per machine, ever, and was not granted again. A fresh trial needs a new sign-up, not a new key.":e&&!1===e.trialGranted?". The welcome trial was withheld this hour (too many new devices from this network)":""}async function autoProvisionApiKey(e,r,t={}){if(Array.isArray(t))throw new TypeError("autoProvisionApiKey takes { app, retryDelaysMs }; a bare retry array is no longer accepted");if(e.apiKey)return{ok:!0,key:e.apiKey};const{app:i,retryDelaysMs:n=DEVICE_REGISTER_RETRY_DELAYS_MS}=t;let o=null;for(let t=0;t<=n.length;t++){t>0&&await new Promise((e=>setTimeout(e,n[t-1])));const a=new AbortController,s=setTimeout((()=>a.abort()),8e3);try{const t=await e.registerDevice(machineFingerprint(),{app:i,signal:a.signal}),n=t&&"string"==typeof t.api_key?t.api_key.trim():"";if(!n)return{ok:!1,key:""};const o=r.saveApiKey(n);return e.setApiKey(n),{ok:!!o.ok,key:n,trialGranted:t.trial_granted,reusedAccount:!0===t.reused_account}}catch(e){if(o=e,!isTransientRegisterError(e))break}finally{clearTimeout(s)}}return{ok:!1,key:"",error:o}}module.exports={autoProvisionApiKey:autoProvisionApiKey,provisionFailureText:provisionFailureText,trialOutcomeNote:trialOutcomeNote,DEVICE_REGISTER_TIMEOUT_MS:8e3,DEVICE_REGISTER_RETRY_DELAYS_MS:DEVICE_REGISTER_RETRY_DELAYS_MS};
|