aegis-desktop 0.8.12 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auto.js +423 -0
- package/lib/avatar/packs/core-wardrobe.pack.json +1 -1
- package/lib/avatar/packs/local-voice-core.pack.json +1 -1
- package/lib/avatar/packs/seasonal-winter.pack.json +1 -1
- package/lib/avatar/profile.js +10 -1
- package/lib/fingerprint.js +327 -0
- package/lib/local/autonomous.js +1 -0
- package/lib/local/prompt.js +6 -0
- package/lib/settings.js +154 -1
- package/main.js +316 -17
- package/package.json +2 -2
- package/preload.js +84 -4
- package/renderer/app.js +208 -4
- package/renderer/fingerprint.css +143 -0
- package/renderer/fingerprint.js +583 -0
- package/renderer/index.html +163 -1
- package/vendor/aegis.js +180 -18
- package/vendor/attest.js +439 -0
- package/vendor/authorship-key.js +366 -0
- package/vendor/device-fingerprint.js +85 -3
- package/vendor/fingerprint.js +1061 -0
- package/vendor/presence-constants.js +104 -0
- package/vendor/presence.js +435 -0
- package/vendor/record-vault.js +834 -0
- package/vendor/touch.js +455 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fingerprint.js — the φ(α) panel: authorship key, sealed records, the vault.
|
|
5
|
+
*
|
|
6
|
+
* The IPC surface has existed since 5072779 (`fingerprintStatus` …
|
|
7
|
+
* `fingerprintVaultAudit`, desktop/preload.js:171-183, main.js:750-765) and
|
|
8
|
+
* until now nothing in desktop/renderer/ called any of it —
|
|
9
|
+
* `grep -rn fingerprint desktop/renderer/` returned nothing, so a GUI user had
|
|
10
|
+
* no button for a feature the terminal has had since `aegiscode fingerprint`.
|
|
11
|
+
* This file is that button.
|
|
12
|
+
*
|
|
13
|
+
* It obeys the same two rules the backend half already keeps:
|
|
14
|
+
*
|
|
15
|
+
* 1. **The key never reaches the DOM.** No payload from main carries
|
|
16
|
+
* `secret`, and nothing here invents a field that might. What gets painted
|
|
17
|
+
* is `status.keyFingerprint` — the derived 12-hex tag — which is public by
|
|
18
|
+
* construction (client/authorship-key.js prints exactly this).
|
|
19
|
+
* 2. **A refusal is shown, not swallowed.** Every handler resolves
|
|
20
|
+
* `{ ok: false, error }` instead of throwing, so the panel prints the
|
|
21
|
+
* reason ("this key file is readable by other users", "rclone is not
|
|
22
|
+
* installed") rather than an empty state that reads like success.
|
|
23
|
+
*
|
|
24
|
+
* Pure formatting (`keyLine`, `anchorVerdict`, `auditVerdict`, …) is kept
|
|
25
|
+
* separate from painting so test/renderer-fingerprint.test.mjs can assert the
|
|
26
|
+
* strings that carry the three-claim anchor verdict without standing up a DOM —
|
|
27
|
+
* and the functions it asserts are the exact ones that paint.
|
|
28
|
+
*
|
|
29
|
+
* Classic script, no imports: loaded before app.js by index.html and called
|
|
30
|
+
* from init(). It does not auto-mount, so requiring it under test has no side
|
|
31
|
+
* effects.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
(function (root, factory) {
|
|
35
|
+
const api = factory();
|
|
36
|
+
// `module` first: under Node the test requires this file directly, and the
|
|
37
|
+
// renderer's `window` is not there.
|
|
38
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
39
|
+
if (root) root.aegisFingerprint = api;
|
|
40
|
+
})(typeof window !== 'undefined' ? window : null, function () {
|
|
41
|
+
/** Every id this file reads. The test asserts each one exists in index.html —
|
|
42
|
+
* a typo here is otherwise a silent no-op, which is how a panel ships with
|
|
43
|
+
* one dead button and no error. */
|
|
44
|
+
const IDS = {
|
|
45
|
+
cardStatus: 'fp-card-status',
|
|
46
|
+
cardOpen: 'fp-open',
|
|
47
|
+
overlay: 'fp-overlay',
|
|
48
|
+
backdrop: 'fp-backdrop',
|
|
49
|
+
close: 'fp-close',
|
|
50
|
+
keySource: 'fp-key-source',
|
|
51
|
+
keyPath: 'fp-key-path',
|
|
52
|
+
keyTag: 'fp-key-tag',
|
|
53
|
+
keyWarning: 'fp-key-warning',
|
|
54
|
+
vaultTarget: 'fp-vault-target',
|
|
55
|
+
label: 'fp-label',
|
|
56
|
+
publishOnMint: 'fp-publish-on-mint',
|
|
57
|
+
autoSeal: 'fp-auto-seal',
|
|
58
|
+
autoSealLabel: 'fp-auto-seal-label',
|
|
59
|
+
autoHint: 'fp-auto-hint',
|
|
60
|
+
sealNow: 'fp-seal-now',
|
|
61
|
+
keygen: 'fp-keygen',
|
|
62
|
+
seal: 'fp-seal',
|
|
63
|
+
sealCard: 'fp-seal-card',
|
|
64
|
+
verify: 'fp-verify',
|
|
65
|
+
publish: 'fp-publish',
|
|
66
|
+
anchor: 'fp-anchor',
|
|
67
|
+
backupTo: 'fp-backup-to',
|
|
68
|
+
backup: 'fp-backup',
|
|
69
|
+
vault: 'fp-vault',
|
|
70
|
+
audit: 'fp-audit',
|
|
71
|
+
vaultList: 'fp-vault-list',
|
|
72
|
+
out: 'fp-out',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ------------------------------------------------------------- formatting
|
|
76
|
+
|
|
77
|
+
const dash = (v) => (v === null || v === undefined || v === '' ? '–' : String(v));
|
|
78
|
+
|
|
79
|
+
/** The one-line state that goes in the sidebar card, where there is room for
|
|
80
|
+
* a sentence and not a table. */
|
|
81
|
+
function keyLine(status) {
|
|
82
|
+
if (!status) return 'checking…';
|
|
83
|
+
if (status.ok === false) return status.error || 'unavailable';
|
|
84
|
+
if (!status.configured) return 'no key — mint one to seal records';
|
|
85
|
+
return `key ${dash(status.keyFingerprint)} · ${dash(status.keySource)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Everything the panel can say about the key without saying the key. */
|
|
89
|
+
function statusRows(status) {
|
|
90
|
+
if (!status || status.ok === false) {
|
|
91
|
+
return [{ k: 'state', v: (status && status.error) || 'unavailable' }];
|
|
92
|
+
}
|
|
93
|
+
const rows = [
|
|
94
|
+
{ k: 'key', v: status.configured ? status.keyFingerprint : 'none' },
|
|
95
|
+
{ k: 'source', v: dash(status.keySource) },
|
|
96
|
+
{ k: 'expected at', v: dash(status.defaultKeyPath) },
|
|
97
|
+
{ k: 'history', v: status.historyPresent ? status.historyPath : 'none yet' },
|
|
98
|
+
];
|
|
99
|
+
// Only when it resolves somewhere else than expected is it worth a row: a
|
|
100
|
+
// key coming from AEGIS_AUTHORSHIP_SECRET or --secret is exactly the
|
|
101
|
+
// situation where "which key am I actually signing with" needs an answer.
|
|
102
|
+
if (status.configured && status.keyPath && status.keyPath !== status.defaultKeyPath) {
|
|
103
|
+
rows.splice(2, 0, { k: 'in use', v: status.keyPath });
|
|
104
|
+
}
|
|
105
|
+
return rows;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The 0644 refusal is the one failure where the fix is a command the user
|
|
109
|
+
* can run, so it is surfaced as a warning line rather than buried in the
|
|
110
|
+
* result pane. */
|
|
111
|
+
function keyWarning(status) {
|
|
112
|
+
const err = status && status.error ? String(status.error) : '';
|
|
113
|
+
return /chmod/i.test(err) ? err : '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One line per vault entry. Tolerant of the key the shared module chooses,
|
|
117
|
+
* because the panel's job here is to show what came back, not to insist. */
|
|
118
|
+
function vaultRows(list) {
|
|
119
|
+
if (!list || list.ok === false) {
|
|
120
|
+
return [{ name: 'vault unavailable', detail: (list && list.error) || 'unknown error' }];
|
|
121
|
+
}
|
|
122
|
+
const items = Array.isArray(list.records) ? list.records
|
|
123
|
+
: Array.isArray(list.items) ? list.items
|
|
124
|
+
: Array.isArray(list.entries) ? list.entries
|
|
125
|
+
: [];
|
|
126
|
+
if (!items.length) return [{ name: 'nothing published yet', detail: '' }];
|
|
127
|
+
return items.map((it) => {
|
|
128
|
+
const name = it.name || it.file || it.path || it.id || 'record';
|
|
129
|
+
const parts = [it.id, it.publishedAt || it.createdAt, it.url].filter(Boolean);
|
|
130
|
+
return { name: String(name), detail: parts.join(' · ') };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The anchor answer, kept as three separate claims on purpose: "the file is
|
|
135
|
+
* gone", "the file was replaced" and "the key that sealed it has moved on"
|
|
136
|
+
* are three different situations, and one boolean hides the interesting one
|
|
137
|
+
* (client/record-vault.js checkAnchor). `present` is read as well as
|
|
138
|
+
* `presence` because that spelling was a real bug once. */
|
|
139
|
+
function anchorVerdict(a) {
|
|
140
|
+
if (!a || a.ok === false) {
|
|
141
|
+
return { ok: false, label: (a && a.error) || 'anchor check failed', claims: [] };
|
|
142
|
+
}
|
|
143
|
+
const present = a.present !== undefined ? a.present : a.presence;
|
|
144
|
+
const claims = [
|
|
145
|
+
{ k: 'present', v: present },
|
|
146
|
+
{ k: 'unchanged', v: a.unchanged },
|
|
147
|
+
{ k: 'attested', v: a.attested },
|
|
148
|
+
];
|
|
149
|
+
const ok = present === true && a.unchanged === true && a.attested === true;
|
|
150
|
+
return {
|
|
151
|
+
ok,
|
|
152
|
+
// Ambiguity is not success: an undefined claim is reported as unknown,
|
|
153
|
+
// never folded into a yes.
|
|
154
|
+
label: ok ? 'the published copy is this record' : 'the published copy is not verifiably this record',
|
|
155
|
+
claims,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What the audit found. A finding is a fact about the vault, not an error
|
|
160
|
+
* from it, so a clean run says so explicitly rather than showing nothing. */
|
|
161
|
+
function auditVerdict(a) {
|
|
162
|
+
if (!a || a.ok === false) return { ok: false, label: (a && a.error) || 'audit failed', findings: [] };
|
|
163
|
+
const findings = Array.isArray(a.findings) ? a.findings
|
|
164
|
+
: Array.isArray(a.leaks) ? a.leaks
|
|
165
|
+
: Array.isArray(a.hits) ? a.hits
|
|
166
|
+
: [];
|
|
167
|
+
if (findings.length) {
|
|
168
|
+
return { ok: false, label: `${findings.length} item(s) in the vault look like key material`, findings };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, label: 'no key material in the vault', findings: [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A minted record as the panel reports it. The local file is the evidence —
|
|
174
|
+
* a failed publish does not unseal it (desktop/lib/fingerprint.js mint) —
|
|
175
|
+
* so an upload failure is said beside the seal, not instead of it. */
|
|
176
|
+
function sealLine(r) {
|
|
177
|
+
if (!r || r.ok === false) return { ok: false, text: (r && r.error) || 'seal failed' };
|
|
178
|
+
const rec = r.record || {};
|
|
179
|
+
const bits = [`sealed ${dash(rec.id)}`, `key ${dash(rec.keyFingerprint)}`, r.out];
|
|
180
|
+
// Both halves of the session, in the record and in the line: the key that
|
|
181
|
+
// sealed it, and the mind that answered. An un-nominated AI credential reads
|
|
182
|
+
// as "ai unobserved", never as a borrowed key.
|
|
183
|
+
bits.push(
|
|
184
|
+
rec.aiKey
|
|
185
|
+
? `ai ${dash(rec.ai)} on ${dash(rec.aiKey)}`
|
|
186
|
+
: `ai unobserved${rec.ai ? ` (${dash(rec.ai)})` : ''}`,
|
|
187
|
+
);
|
|
188
|
+
if (r.vault && r.vault.url) bits.push(`published ${r.vault.url}`);
|
|
189
|
+
else if (r.vault) bits.push(`published ${dash(r.vault.remote || r.vault.path)}`);
|
|
190
|
+
if (r.vaultError) bits.push(`publish failed: ${r.vaultError}`);
|
|
191
|
+
return { ok: true, text: bits.filter(Boolean).join(' · '), file: r.out || null, record: r.record || null };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------- painting
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One line describing the auto-seal state, in the same voice as the rest of
|
|
198
|
+
* the panel: what is armed, what it has filed, and the last thing that went
|
|
199
|
+
* wrong. Pure, so the test asserts the string the panel paints.
|
|
200
|
+
*/
|
|
201
|
+
function autoMintLine(s) {
|
|
202
|
+
if (!s || typeof s !== 'object') return '–';
|
|
203
|
+
if (s.enabled !== true) return 'off — sealing stays a button';
|
|
204
|
+
const bits = [];
|
|
205
|
+
const secs = Number(s.quietMs);
|
|
206
|
+
bits.push(Number.isFinite(secs) ? `seals ${Math.round(secs / 1000)}s after the last turn` : 'seals when the session goes quiet');
|
|
207
|
+
if (s.publish) bits.push('publishes to the vault');
|
|
208
|
+
if (s.pendingTurns) bits.push(`${s.pendingTurns} turn${s.pendingTurns === 1 ? '' : 's'} pending`);
|
|
209
|
+
if (s.seals) bits.push(`${s.seals} sealed`);
|
|
210
|
+
if (s.failures) bits.push(`${s.failures} failed`);
|
|
211
|
+
if (s.lastError) bits.push(`last error: ${s.lastError}`);
|
|
212
|
+
return bits.join(' · ');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mount({ api, doc, auto } = {}) {
|
|
216
|
+
const d = doc || (typeof document !== 'undefined' ? document : null);
|
|
217
|
+
if (!d) throw new Error('fingerprint: mount needs a document');
|
|
218
|
+
const bridge = api || (typeof window !== 'undefined' ? window.aegis : null);
|
|
219
|
+
// The automation bridge (main.js registerAutoIpc). Injectable for the same
|
|
220
|
+
// reason `api` is, and optional: a preload predating that surface leaves the
|
|
221
|
+
// auto-seal control inert rather than throwing at mount.
|
|
222
|
+
const autoBridge = auto || (typeof window !== 'undefined' ? window.auto : null);
|
|
223
|
+
|
|
224
|
+
const el = (id) => d.getElementById(id);
|
|
225
|
+
const state = { status: null, last: null, busy: false, mounted: true };
|
|
226
|
+
|
|
227
|
+
function say(text, bad) {
|
|
228
|
+
const out = el(IDS.out);
|
|
229
|
+
if (!out) return;
|
|
230
|
+
out.textContent = text;
|
|
231
|
+
out.className = bad ? 'fp-out fp-bad' : 'fp-out';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Every bridge call goes through here. A handler that rejects (Electron
|
|
235
|
+
* turns a thrown main-process error into a rejected invoke) must not leave
|
|
236
|
+
* the panel mid-action, so the catch produces the same `{ ok, error }`
|
|
237
|
+
* shape the handlers themselves do. */
|
|
238
|
+
async function call(name, payload) {
|
|
239
|
+
if (!bridge || typeof bridge[name] !== 'function') {
|
|
240
|
+
return { ok: false, error: `this build has no ${name} bridge` };
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const r = await bridge[name](payload);
|
|
244
|
+
if (!r || typeof r !== 'object') return { ok: false, error: `${name} returned nothing` };
|
|
245
|
+
return r;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function paintStatus() {
|
|
252
|
+
const s = state.status;
|
|
253
|
+
const card = el(IDS.cardStatus);
|
|
254
|
+
if (card) card.textContent = keyLine(s);
|
|
255
|
+
const src = el(IDS.keySource);
|
|
256
|
+
const pth = el(IDS.keyPath);
|
|
257
|
+
const tag = el(IDS.keyTag);
|
|
258
|
+
const warn = el(IDS.keyWarning);
|
|
259
|
+
const vt = el(IDS.vaultTarget);
|
|
260
|
+
const rows = statusRows(s);
|
|
261
|
+
const pick = (k) => {
|
|
262
|
+
const row = rows.find((r) => r.k === k);
|
|
263
|
+
return row ? row.v : '–';
|
|
264
|
+
};
|
|
265
|
+
if (src) src.textContent = pick('source');
|
|
266
|
+
if (pth) pth.textContent = s && s.keyPath ? s.keyPath : pick('expected at');
|
|
267
|
+
if (tag) tag.textContent = s && s.configured ? dash(s.keyFingerprint) : 'none';
|
|
268
|
+
if (warn) {
|
|
269
|
+
const w = keyWarning(s);
|
|
270
|
+
warn.textContent = w;
|
|
271
|
+
warn.hidden = !w;
|
|
272
|
+
}
|
|
273
|
+
if (vt && s && s.vault) {
|
|
274
|
+
vt.textContent = `${dash(s.vault.remote)}:${dash(s.vault.recordsDir || s.vault.root)}`;
|
|
275
|
+
} else if (vt) {
|
|
276
|
+
vt.textContent = '–';
|
|
277
|
+
}
|
|
278
|
+
// A key that is present makes keygen a REPLACE, which orphans every
|
|
279
|
+
// record sealed with the old one. Saying that on the button is the whole
|
|
280
|
+
// warning; the confirm below is the second line of defence.
|
|
281
|
+
const kg = el(IDS.keygen);
|
|
282
|
+
if (kg) kg.textContent = s && s.configured ? 'Replace key…' : 'Mint key';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function paintVault(list) {
|
|
286
|
+
const ul = el(IDS.vaultList);
|
|
287
|
+
if (!ul) return;
|
|
288
|
+
ul.textContent = '';
|
|
289
|
+
for (const row of vaultRows(list)) {
|
|
290
|
+
const li = d.createElement('li');
|
|
291
|
+
li.className = 'fp-vault-row';
|
|
292
|
+
const name = d.createElement('span');
|
|
293
|
+
name.className = 'fp-vault-name';
|
|
294
|
+
name.textContent = row.name;
|
|
295
|
+
li.appendChild(name);
|
|
296
|
+
if (row.detail) {
|
|
297
|
+
const det = d.createElement('span');
|
|
298
|
+
det.className = 'fp-vault-detail';
|
|
299
|
+
det.textContent = row.detail;
|
|
300
|
+
li.appendChild(det);
|
|
301
|
+
}
|
|
302
|
+
ul.appendChild(li);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function refresh() {
|
|
307
|
+
state.status = await call('fingerprintStatus');
|
|
308
|
+
paintStatus();
|
|
309
|
+
await loadAutoMint();
|
|
310
|
+
return state.status;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The auto-seal control (settings.js `__autoMint`, through lib/auto.js).
|
|
315
|
+
*
|
|
316
|
+
* Read-only paint from MAIN, for the reason the rest of this panel is: the
|
|
317
|
+
* renderer must never be the place that decides a record gets filed, and a
|
|
318
|
+
* checkbox state this file invented would disagree with the timer the moment
|
|
319
|
+
* anything else (the queue card's window, a restart) changed the setting.
|
|
320
|
+
*/
|
|
321
|
+
function renderAutoMint(s) {
|
|
322
|
+
if (!s || typeof s !== 'object') return;
|
|
323
|
+
const sw = el(IDS.autoSeal);
|
|
324
|
+
if (sw) sw.checked = s.enabled === true;
|
|
325
|
+
const label = el(IDS.autoSealLabel);
|
|
326
|
+
if (label) label.textContent = s.enabled ? 'sealing automatically when quiet' : 'seal automatically when quiet';
|
|
327
|
+
const hint = el(IDS.autoHint);
|
|
328
|
+
if (!hint) return;
|
|
329
|
+
hint.textContent = autoMintLine(s);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function loadAutoMint() {
|
|
333
|
+
if (!autoBridge || typeof autoBridge.status !== 'function') {
|
|
334
|
+
const hint = el(IDS.autoHint);
|
|
335
|
+
// Said plainly rather than left blank: an inert toggle with no
|
|
336
|
+
// explanation is the failure mode this whole panel exists to avoid.
|
|
337
|
+
if (hint) hint.textContent = 'this build has no automation bridge — sealing stays a button';
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const r = await autoBridge.status();
|
|
342
|
+
if (r && r.mint) renderAutoMint(r.mint);
|
|
343
|
+
return r;
|
|
344
|
+
} catch (e) {
|
|
345
|
+
const hint = el(IDS.autoHint);
|
|
346
|
+
if (hint) hint.textContent = `automation status unavailable: ${(e && e.message) || String(e)}`;
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Persist the switch through main and paint main's answer. */
|
|
352
|
+
async function setAutoMint(enabled) {
|
|
353
|
+
if (!autoBridge || typeof autoBridge.setMint !== 'function') return null;
|
|
354
|
+
const publish = Boolean(el(IDS.publishOnMint) && el(IDS.publishOnMint).checked);
|
|
355
|
+
try {
|
|
356
|
+
const r = await autoBridge.setMint({ enabled: Boolean(enabled), publish });
|
|
357
|
+
if (r && r.mint) renderAutoMint(r.mint);
|
|
358
|
+
else if (r && r.ok === false) say(r.error || 'could not save that', true);
|
|
359
|
+
return r;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
say(`could not save that: ${(e && e.message) || String(e)}`, true);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Seal right now, through the automation rather than the raw mint: the
|
|
367
|
+
* same call resets the pending-turn counter, so the quiet timer will not
|
|
368
|
+
* file a second identical record for this session. */
|
|
369
|
+
async function doSealNow() {
|
|
370
|
+
const publish = Boolean(el(IDS.publishOnMint) && el(IDS.publishOnMint).checked);
|
|
371
|
+
return action('seal now', async () => {
|
|
372
|
+
if (!autoBridge || typeof autoBridge.sealNow !== 'function') {
|
|
373
|
+
return { ok: false, error: 'this build has no automation bridge' };
|
|
374
|
+
}
|
|
375
|
+
return autoBridge.sealNow(publish);
|
|
376
|
+
}, {
|
|
377
|
+
paint: (r) => { if (r && r.ok && r.out) state.last = { file: r.out, record: r.record || null }; },
|
|
378
|
+
say: (r) => {
|
|
379
|
+
if (!r) return 'seal now: nothing happened';
|
|
380
|
+
if (r.ok === false) return `seal failed: ${r.error || 'unknown'}`;
|
|
381
|
+
if (r.type === 'skip') return `nothing to seal (${r.reason || 'no new turns'})`;
|
|
382
|
+
const line = sealLine(r);
|
|
383
|
+
return line.ok ? `sealed ${dash((r.record || {}).id)} → ${dash(r.out)}` : `seal failed: ${line.text}`;
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Run one action, repaint what it changed, and say what it did. `confirm`
|
|
389
|
+
* is injectable so the harness can answer it deterministically. */
|
|
390
|
+
async function action(label, fn, opts) {
|
|
391
|
+
const o = opts || {};
|
|
392
|
+
if (state.busy) return null;
|
|
393
|
+
state.busy = true;
|
|
394
|
+
say(`${label}…`, false);
|
|
395
|
+
try {
|
|
396
|
+
const r = await fn();
|
|
397
|
+
if (o.paint) o.paint(r);
|
|
398
|
+
if (o.after) await o.after(r);
|
|
399
|
+
say(o.say ? o.say(r) : `${label}: ${r && r.ok === false ? r.error : 'done'}`, Boolean(r && r.ok === false));
|
|
400
|
+
return r;
|
|
401
|
+
} finally {
|
|
402
|
+
state.busy = false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const doKeygen = () =>
|
|
407
|
+
action('mint key', () => call('fingerprintKeygen', {
|
|
408
|
+
force: Boolean(state.status && state.status.configured),
|
|
409
|
+
}), {
|
|
410
|
+
// Only after a successful mint: status moved from "no key" to a key,
|
|
411
|
+
// and repainting is what turns "Mint key" into "Replace key…".
|
|
412
|
+
after: refresh,
|
|
413
|
+
say: (r) => (r.ok
|
|
414
|
+
? `minted ${dash(r.keyFingerprint)} at ${dash(r.path)}`
|
|
415
|
+
: `mint failed: ${r.error}`),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const doSeal = () =>
|
|
419
|
+
action('seal session', () => call('fingerprintMint', {
|
|
420
|
+
label: (el(IDS.label) && el(IDS.label).value.trim()) || null,
|
|
421
|
+
publish: Boolean(el(IDS.publishOnMint) && el(IDS.publishOnMint).checked),
|
|
422
|
+
}), {
|
|
423
|
+
paint: (r) => { if (r.ok) state.last = { file: r.out, record: r.record }; },
|
|
424
|
+
say: (r) => {
|
|
425
|
+
const line = sealLine(r);
|
|
426
|
+
return line.ok ? `sealed ${dash((r.record || {}).id)} → ${dash(r.out)}` : `seal failed: ${line.text}`;
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/** Verify/publish/anchor act on the record this panel sealed. A path is
|
|
431
|
+
* preferred over the in-memory record because the sha256 in the anchor is
|
|
432
|
+
* then about the bytes on disk rather than a re-serialisation of them
|
|
433
|
+
* (desktop/lib/fingerprint.js publish makes the same argument) — so `file`
|
|
434
|
+
* wins when there is one, and the record is the fallback for a host where
|
|
435
|
+
* the write failed but the seal did not. */
|
|
436
|
+
const doVerify = () =>
|
|
437
|
+
action('verify', async () => {
|
|
438
|
+
if (state.last && state.last.file) return call('fingerprintVerify', { file: state.last.file });
|
|
439
|
+
if (state.last && state.last.record) return call('fingerprintVerify', { record: state.last.record });
|
|
440
|
+
return { ok: false, error: 'nothing sealed in this window yet — seal a session first' };
|
|
441
|
+
}, {
|
|
442
|
+
say: (r) => (r.ok ? `verified ${dash(r.id)} · key ${dash(r.keySource)}` : `not verified: ${r.error || r.reason}`),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const doPublish = () =>
|
|
446
|
+
action('publish', () => {
|
|
447
|
+
if (state.last && state.last.file) return call('fingerprintPublish', { file: state.last.file });
|
|
448
|
+
if (state.last && state.last.record) return call('fingerprintPublish', { record: state.last.record });
|
|
449
|
+
return { ok: false, error: 'nothing sealed in this window yet — seal a session first' };
|
|
450
|
+
}, {
|
|
451
|
+
say: (r) => (r.ok
|
|
452
|
+
? `published ${dash(r.url || r.path)}${r.sha256 ? ` · sha256 ${String(r.sha256).slice(0, 12)}` : ''}`
|
|
453
|
+
: `publish failed: ${r.error}`),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const doAnchor = () =>
|
|
457
|
+
action('check anchor', () => {
|
|
458
|
+
if (state.last && state.last.file) return call('fingerprintAnchor', { file: state.last.file });
|
|
459
|
+
return call('fingerprintAnchor', {});
|
|
460
|
+
}, {
|
|
461
|
+
say: (r) => {
|
|
462
|
+
const v = anchorVerdict(r);
|
|
463
|
+
const claims = v.claims.map((c) => `${c.k} ${c.v === true ? 'yes' : c.v === false ? 'no' : 'unknown'}`).join(' · ');
|
|
464
|
+
return `${v.label}${claims ? ` — ${claims}` : ''}`;
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const doBackup = () =>
|
|
469
|
+
action('back up key', () => {
|
|
470
|
+
const to = el(IDS.backupTo) && el(IDS.backupTo).value.trim();
|
|
471
|
+
if (!to) return { ok: false, error: 'backup needs a destination directory' };
|
|
472
|
+
return call('fingerprintBackup', { to });
|
|
473
|
+
}, {
|
|
474
|
+
say: (r) => (r.ok ? `key copied to ${dash(r.to || r.path)}` : `backup refused: ${r.error}`),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const doVault = () =>
|
|
478
|
+
action('list vault', () => call('fingerprintVault'), {
|
|
479
|
+
paint: paintVault,
|
|
480
|
+
say: (r) => (r.ok ? `vault listed — ${vaultRows(r).length} entry(ies)` : `vault unavailable: ${r.error}`),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const doAudit = () =>
|
|
484
|
+
action('audit vault', () => call('fingerprintVaultAudit'), {
|
|
485
|
+
say: (r) => {
|
|
486
|
+
const v = auditVerdict(r);
|
|
487
|
+
const names = v.findings.map((f) => (f && (f.path || f.name)) || String(f)).slice(0, 3);
|
|
488
|
+
return `${v.label}${names.length ? ` — ${names.join(', ')}` : ''}`;
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const isOpen = () => {
|
|
493
|
+
const o = el(IDS.overlay);
|
|
494
|
+
return Boolean(o) && !o.hidden;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
async function open() {
|
|
498
|
+
const o = el(IDS.overlay);
|
|
499
|
+
if (!o) return;
|
|
500
|
+
o.hidden = false;
|
|
501
|
+
// Body class so the page behind cannot scroll under the sheet, matching
|
|
502
|
+
// the memory inspector's `.memory-open` rule.
|
|
503
|
+
if (d.body && d.body.classList) d.body.classList.add('fp-open');
|
|
504
|
+
await refresh();
|
|
505
|
+
await doVault();
|
|
506
|
+
const f = el(IDS.close);
|
|
507
|
+
if (f) f.focus();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function close() {
|
|
511
|
+
const o = el(IDS.overlay);
|
|
512
|
+
if (!o) return;
|
|
513
|
+
o.hidden = true;
|
|
514
|
+
if (d.body && d.body.classList) d.body.classList.remove('fp-open');
|
|
515
|
+
const btn = el(IDS.cardOpen);
|
|
516
|
+
if (btn) btn.focus();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ------------------------------------------------------------- listeners
|
|
520
|
+
// Bound here rather than in app.js so the panel owns its own markup: every
|
|
521
|
+
// id in IDS is read by this file only, and the test asserts they all exist.
|
|
522
|
+
const bind = (id, type, fn) => {
|
|
523
|
+
const node = el(id);
|
|
524
|
+
if (node) node.addEventListener(type, fn);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
bind(IDS.cardOpen, 'click', open);
|
|
528
|
+
bind(IDS.close, 'click', close);
|
|
529
|
+
bind(IDS.backdrop, 'click', close);
|
|
530
|
+
bind(IDS.keygen, 'click', doKeygen);
|
|
531
|
+
bind(IDS.seal, 'click', doSeal);
|
|
532
|
+
// The sidebar's one-click seal runs the same action; on success it also
|
|
533
|
+
// opens the panel, because a record filed silently behind a closed sheet is
|
|
534
|
+
// indistinguishable from a button that did nothing.
|
|
535
|
+
bind(IDS.sealCard, 'click', async () => {
|
|
536
|
+
const r = await doSeal();
|
|
537
|
+
if (r && r.ok) await open();
|
|
538
|
+
});
|
|
539
|
+
bind(IDS.verify, 'click', doVerify);
|
|
540
|
+
bind(IDS.publish, 'click', doPublish);
|
|
541
|
+
bind(IDS.anchor, 'click', doAnchor);
|
|
542
|
+
bind(IDS.backup, 'click', doBackup);
|
|
543
|
+
bind(IDS.vault, 'click', doVault);
|
|
544
|
+
bind(IDS.audit, 'click', doAudit);
|
|
545
|
+
// The auto-seal switch is the ONE control on this panel that can file a
|
|
546
|
+
// record with nobody watching, so it is the one that must repaint from
|
|
547
|
+
// main's answer rather than from the checkbox the user just clicked.
|
|
548
|
+
if (el(IDS.autoSeal)) {
|
|
549
|
+
bind(IDS.autoSeal, 'change', () => {
|
|
550
|
+
const box = el(IDS.autoSeal);
|
|
551
|
+
return setAutoMint(Boolean(box && box.checked));
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
if (el(IDS.sealNow)) bind(IDS.sealNow, 'click', doSealNow);
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
open,
|
|
558
|
+
close,
|
|
559
|
+
isOpen,
|
|
560
|
+
refresh,
|
|
561
|
+
loadAutoMint,
|
|
562
|
+
state,
|
|
563
|
+
// Exposed for the test and for app.js's on-demand repaint. Every one is
|
|
564
|
+
// idempotent, so calling them from a console is safe.
|
|
565
|
+
actions: { doKeygen, doSeal, doSealNow, doVerify, doPublish, doAnchor, doBackup, doVault, doAudit },
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
IDS,
|
|
571
|
+
mount,
|
|
572
|
+
// Pure, and asserted as such by the test.
|
|
573
|
+
keyLine,
|
|
574
|
+
statusRows,
|
|
575
|
+
keyWarning,
|
|
576
|
+
vaultRows,
|
|
577
|
+
anchorVerdict,
|
|
578
|
+
auditVerdict,
|
|
579
|
+
sealLine,
|
|
580
|
+
autoMintLine,
|
|
581
|
+
dash,
|
|
582
|
+
};
|
|
583
|
+
});
|