sealkeep 0.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/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { fail } from "./errors.js";
|
|
3
|
+
import { BIP39_ENGLISH } from "./bip39-wordlist.js";
|
|
4
|
+
/**
|
|
5
|
+
* Transcribable recovery phrases, in the format the rest of the world already
|
|
6
|
+
* uses.
|
|
7
|
+
*
|
|
8
|
+
* A recovery phrase is written on paper by a person under stress, months before
|
|
9
|
+
* it matters, and typed back on a different machine. Base64 fails all three
|
|
10
|
+
* tests. Words do not: they survive bad handwriting, they can be read aloud,
|
|
11
|
+
* and a checksum catches the transcription mistakes people actually make.
|
|
12
|
+
*
|
|
13
|
+
* This used to be a bespoke 256-word list, which worked but left the phrase
|
|
14
|
+
* marooned in one program. BIP-39 is the same idea with an installed base:
|
|
15
|
+
* hardware wallets, steel backup plates, password managers and phone apps all
|
|
16
|
+
* know how to hold these words, and people who have done this before recognise
|
|
17
|
+
* them on sight. Twenty-four words carry 256 bits of entropy; twelve carry 128
|
|
18
|
+
* and are accepted for people who want a shorter thing to write down.
|
|
19
|
+
*
|
|
20
|
+
* What this does *not* mean is that a wallet can open an archive. A wallet is
|
|
21
|
+
* somewhere to keep the words, not a second key: Sealkeep still derives its
|
|
22
|
+
* key from the phrase with scrypt and a per-vault salt, which is deliberately
|
|
23
|
+
* slower and better salted than BIP-39's own seed derivation. Anyone claiming
|
|
24
|
+
* otherwise is selling something.
|
|
25
|
+
*
|
|
26
|
+
* The list is frozen by the specification and verified by test rather than
|
|
27
|
+
* trusted, because one altered word would produce phrases no other tool reads.
|
|
28
|
+
*/
|
|
29
|
+
export const WORDS = BIP39_ENGLISH;
|
|
30
|
+
/** 256 bits, so twenty-four words. Twelve-word phrases are read but not issued. */
|
|
31
|
+
export const ENTROPY_BYTES = 32;
|
|
32
|
+
export const PHRASE_WORDS = 24;
|
|
33
|
+
/** The entropy sizes BIP-39 defines, and the word count each produces. */
|
|
34
|
+
const SIZES = [16, 20, 24, 28, 32].map((bytes) => ({ bytes, words: (bytes * 8 + (bytes * 8) / 32) / 11 }));
|
|
35
|
+
const INDEX = new Map(WORDS.map((word, index) => [word, index]));
|
|
36
|
+
/** Normalizes what a person actually types: stray case, punctuation, and runs of spaces. */
|
|
37
|
+
export function normalize(phrase) {
|
|
38
|
+
return phrase.toLowerCase().replace(/[^a-z\s-]/g, " ").split(/[\s-]+/).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
/** The BIP-39 checksum: the leading ENT/32 bits of SHA-256 over the entropy. */
|
|
41
|
+
function checksumBits(entropy) {
|
|
42
|
+
const bits = (entropy.length * 8) / 32;
|
|
43
|
+
const digest = createHash("sha256").update(entropy).digest();
|
|
44
|
+
return [...digest].map((byte) => byte.toString(2).padStart(8, "0")).join("").slice(0, bits);
|
|
45
|
+
}
|
|
46
|
+
export function entropyToPhrase(entropy) {
|
|
47
|
+
if (!SIZES.some((size) => size.bytes === entropy.length)) {
|
|
48
|
+
fail("invalid_argument", `A recovery phrase needs ${SIZES.map((size) => size.bytes).join(", ")} bytes of entropy, not ${entropy.length}`);
|
|
49
|
+
}
|
|
50
|
+
const bits = [...entropy].map((byte) => byte.toString(2).padStart(8, "0")).join("") + checksumBits(entropy);
|
|
51
|
+
const words = [];
|
|
52
|
+
for (let at = 0; at < bits.length; at += 11)
|
|
53
|
+
words.push(WORDS[parseInt(bits.slice(at, at + 11), 2)]);
|
|
54
|
+
return words.join(" ");
|
|
55
|
+
}
|
|
56
|
+
export function generateRecoveryPhrase() {
|
|
57
|
+
return entropyToPhrase(randomBytes(ENTROPY_BYTES));
|
|
58
|
+
}
|
|
59
|
+
/** Words within one edit of the given word, so a typo gets a suggestion rather than a shrug. */
|
|
60
|
+
export function suggestions(word, limit = 3) {
|
|
61
|
+
const distance = (a, b) => {
|
|
62
|
+
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
63
|
+
for (let column = 0; column <= b.length; column += 1)
|
|
64
|
+
rows[0][column] = column;
|
|
65
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
66
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
67
|
+
rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return rows[a.length][b.length];
|
|
71
|
+
};
|
|
72
|
+
// BIP-39 guarantees the first four letters identify a word, so a prefix match
|
|
73
|
+
// is a better guess than raw edit distance and is tried first.
|
|
74
|
+
const prefix = word.slice(0, 4);
|
|
75
|
+
const byPrefix = prefix.length === 4 ? WORDS.filter((candidate) => candidate.startsWith(prefix)) : [];
|
|
76
|
+
const byDistance = WORDS.map((candidate) => ({ candidate, score: distance(word, candidate) }))
|
|
77
|
+
.filter((entry) => entry.score <= 2)
|
|
78
|
+
.sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
|
|
79
|
+
.map((entry) => entry.candidate);
|
|
80
|
+
return [...new Set([...byPrefix, ...byDistance])].slice(0, limit);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Explains exactly what is wrong with a phrase, because "invalid" is useless to
|
|
84
|
+
* someone holding a piece of paper. Unknown words get spelling suggestions; a
|
|
85
|
+
* checksum failure means a word is in the wrong place or was misread.
|
|
86
|
+
*/
|
|
87
|
+
export function inspectPhrase(phrase) {
|
|
88
|
+
const words = normalize(phrase);
|
|
89
|
+
const problems = [];
|
|
90
|
+
const size = SIZES.find((candidate) => candidate.words === words.length);
|
|
91
|
+
if (!size) {
|
|
92
|
+
// Leads with 24 because that is what this build issues, so it is what the
|
|
93
|
+
// reader almost certainly has on paper. The shorter lengths are accepted
|
|
94
|
+
// from other tools and are mentioned second rather than as equal options.
|
|
95
|
+
const others = SIZES.map((candidate) => candidate.words).filter((count) => count !== PHRASE_WORDS);
|
|
96
|
+
problems.push(`Expected ${PHRASE_WORDS} words, found ${words.length}. (${others.join(", ")} are also accepted.)`);
|
|
97
|
+
}
|
|
98
|
+
for (const [position, word] of words.entries()) {
|
|
99
|
+
if (INDEX.has(word))
|
|
100
|
+
continue;
|
|
101
|
+
const near = suggestions(word);
|
|
102
|
+
problems.push(`Word ${position + 1} "${word}" is not in the list.${near.length ? ` Did you mean ${near.join(", ")}?` : ""}`);
|
|
103
|
+
}
|
|
104
|
+
if (problems.length > 0)
|
|
105
|
+
return { valid: false, words, problems };
|
|
106
|
+
const bits = words.map((word) => INDEX.get(word).toString(2).padStart(11, "0")).join("");
|
|
107
|
+
const entropy = Buffer.from((bits.slice(0, size.bytes * 8).match(/.{8}/g) ?? []).map((byte) => parseInt(byte, 2)));
|
|
108
|
+
const expected = Buffer.from(checksumBits(entropy));
|
|
109
|
+
const presented = Buffer.from(bits.slice(size.bytes * 8));
|
|
110
|
+
if (expected.length !== presented.length || !timingSafeEqual(expected, presented)) {
|
|
111
|
+
problems.push("The words are all valid but the checksum does not match, so one is misread or out of order.");
|
|
112
|
+
return { valid: false, words, problems };
|
|
113
|
+
}
|
|
114
|
+
return { valid: true, words, problems };
|
|
115
|
+
}
|
|
116
|
+
/** True when a phrase is one this build generated. Older free-text phrases return false. */
|
|
117
|
+
export function isMnemonic(phrase) {
|
|
118
|
+
return inspectPhrase(phrase).valid;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The form a phrase takes before any key is derived from it.
|
|
122
|
+
*
|
|
123
|
+
* People read words off paper and type them back with different capitalisation and
|
|
124
|
+
* spacing. Deriving a key from the raw string would reject a correct phrase for
|
|
125
|
+
* cosmetic reasons — the worst possible failure for a recovery flow. A recognised
|
|
126
|
+
* word phrase is therefore reduced to canonical lowercase, single-spaced form.
|
|
127
|
+
*
|
|
128
|
+
* Anything that is not a valid word phrase passes through untouched, because vaults
|
|
129
|
+
* created before the word list hold arbitrary text whose exact bytes are the secret.
|
|
130
|
+
*/
|
|
131
|
+
export function canonicalPhrase(phrase) {
|
|
132
|
+
const candidate = normalize(phrase).join(" ");
|
|
133
|
+
return inspectPhrase(candidate).valid ? candidate : phrase;
|
|
134
|
+
}
|
package/dist/src/net.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
function isBusy(port, host) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
const socket = createConnection({ host, port });
|
|
5
|
+
socket.once("connect", () => { socket.destroy(); resolve(true); });
|
|
6
|
+
socket.once("error", () => resolve(false));
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
/** Picks the first free port at or above `preferred` so local services never fight over one. */
|
|
10
|
+
export async function freePort(preferred, host = "127.0.0.1", attempts = 100) {
|
|
11
|
+
for (let offset = 0; offset < attempts; offset += 1) {
|
|
12
|
+
if (!(await isBusy(preferred + offset, host)))
|
|
13
|
+
return preferred + offset;
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`No free port found between ${preferred} and ${preferred + attempts - 1}`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native notifications, with no dependency and no daemon of its own.
|
|
3
|
+
*
|
|
4
|
+
* These are the only moments the product interrupts you, so they are rationed:
|
|
5
|
+
* something was preserved, disk came back, or something needs a decision. Text is
|
|
6
|
+
* counts and outcomes — never a file's contents, never a path from a transcript,
|
|
7
|
+
* never a secret — because a notification lands on a lock screen where anyone can
|
|
8
|
+
* read it.
|
|
9
|
+
*/
|
|
10
|
+
export type Notification = {
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
};
|
|
14
|
+
export type NotifyOptions = {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
platform?: NodeJS.Platform;
|
|
17
|
+
/** Injected in tests so no real notification is raised. */
|
|
18
|
+
exec?: (file: string, args: string[]) => Promise<unknown>;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
};
|
|
21
|
+
/** Off by an explicit opt-out, and off automatically where there is no desktop session. */
|
|
22
|
+
export declare function notificationsEnabled(env?: NodeJS.ProcessEnv, target?: NodeJS.Platform): boolean;
|
|
23
|
+
/** Nothing here is interpolated into a shell; every value is passed as an argument. */
|
|
24
|
+
export declare function notify(notification: Notification, options?: NotifyOptions): Promise<boolean>;
|
|
25
|
+
export type TickSummary = {
|
|
26
|
+
archived: number;
|
|
27
|
+
uploaded: number;
|
|
28
|
+
reclaimed: number;
|
|
29
|
+
failed: number;
|
|
30
|
+
freedBytes?: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Decides whether a round of work is worth interrupting someone for.
|
|
34
|
+
*
|
|
35
|
+
* Routine sealing is not: it happens constantly and saying so would train people to
|
|
36
|
+
* ignore the product. Reclaimed disk is, because a file moved. A failure is, because
|
|
37
|
+
* it needs a decision.
|
|
38
|
+
*/
|
|
39
|
+
export declare function summarize(tick: TickSummary): Notification | null;
|
|
40
|
+
/** Keeps a quiet period between notifications so a busy machine cannot spam. */
|
|
41
|
+
export declare class NotificationGate {
|
|
42
|
+
private readonly quietMs;
|
|
43
|
+
private lastAt;
|
|
44
|
+
constructor(quietMs?: number);
|
|
45
|
+
allow(now: number, urgent?: boolean): boolean;
|
|
46
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
const run = (file, args) => new Promise((resolve) => execFile(file, args, () => resolve()));
|
|
4
|
+
/** Off by an explicit opt-out, and off automatically where there is no desktop session. */
|
|
5
|
+
export function notificationsEnabled(env = process.env, target = platform()) {
|
|
6
|
+
if (env.VAULTLINE_NOTIFICATIONS === "off")
|
|
7
|
+
return false;
|
|
8
|
+
if (target === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
9
|
+
return false;
|
|
10
|
+
return target === "darwin" || target === "linux" || target === "win32";
|
|
11
|
+
}
|
|
12
|
+
/** Nothing here is interpolated into a shell; every value is passed as an argument. */
|
|
13
|
+
export async function notify(notification, options = {}) {
|
|
14
|
+
const target = options.platform ?? platform();
|
|
15
|
+
const env = options.env ?? process.env;
|
|
16
|
+
if (options.enabled === false || (options.enabled === undefined && !notificationsEnabled(env, target)))
|
|
17
|
+
return false;
|
|
18
|
+
const exec = options.exec ?? run;
|
|
19
|
+
const title = notification.title.slice(0, 120);
|
|
20
|
+
const body = notification.body.slice(0, 240);
|
|
21
|
+
if (target === "darwin") {
|
|
22
|
+
const quote = (value) => value.replace(/[\\"]/g, "\\$&");
|
|
23
|
+
await exec("osascript", ["-e", `display notification "${quote(body)}" with title "${quote(title)}"`]);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (target === "linux") {
|
|
27
|
+
await exec("notify-send", ["--app-name=Sealkeep", "--icon=drive-harddisk", title, body]);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (target === "win32") {
|
|
31
|
+
const script = `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
32
|
+
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
33
|
+
$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode($env:VL_TITLE)) > $null
|
|
34
|
+
$xml.GetElementsByTagName('text')[1].AppendChild($xml.CreateTextNode($env:VL_BODY)) > $null
|
|
35
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Sealkeep').Show([Windows.UI.Notifications.ToastNotification]::new($xml))`;
|
|
36
|
+
await exec("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
function bytes(value) {
|
|
42
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
43
|
+
let size = value;
|
|
44
|
+
let unit = 0;
|
|
45
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
46
|
+
size /= 1024;
|
|
47
|
+
unit += 1;
|
|
48
|
+
}
|
|
49
|
+
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decides whether a round of work is worth interrupting someone for.
|
|
53
|
+
*
|
|
54
|
+
* Routine sealing is not: it happens constantly and saying so would train people to
|
|
55
|
+
* ignore the product. Reclaimed disk is, because a file moved. A failure is, because
|
|
56
|
+
* it needs a decision.
|
|
57
|
+
*/
|
|
58
|
+
export function summarize(tick) {
|
|
59
|
+
if (tick.failed > 0) {
|
|
60
|
+
return { title: "Sealkeep needs attention", body: `${tick.failed} session${tick.failed === 1 ? "" : "s"} could not be archived. Run \`sealkeep doctor\`.` };
|
|
61
|
+
}
|
|
62
|
+
if (tick.reclaimed > 0) {
|
|
63
|
+
const freed = tick.freedBytes ? ` and freed ${bytes(tick.freedBytes)}` : "";
|
|
64
|
+
return { title: "Disk reclaimed", body: `Moved ${tick.reclaimed} archived session${tick.reclaimed === 1 ? "" : "s"} to the trash${freed}. The archives are safe.` };
|
|
65
|
+
}
|
|
66
|
+
if (tick.archived >= 5) {
|
|
67
|
+
return { title: "Sessions preserved", body: `Sealed ${tick.archived} agent sessions${tick.uploaded ? `, ${tick.uploaded} copied off this machine` : ""}.` };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Keeps a quiet period between notifications so a busy machine cannot spam. */
|
|
72
|
+
export class NotificationGate {
|
|
73
|
+
quietMs;
|
|
74
|
+
lastAt = 0;
|
|
75
|
+
constructor(quietMs = 15 * 60_000) {
|
|
76
|
+
this.quietMs = quietMs;
|
|
77
|
+
}
|
|
78
|
+
allow(now, urgent = false) {
|
|
79
|
+
if (!urgent && now - this.lastAt < this.quietMs)
|
|
80
|
+
return false;
|
|
81
|
+
this.lastAt = now;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ArchiveRecord } from "./types.js";
|
|
2
|
+
import { createActiveLease } from "./providers/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Removing the local copy of an archive that lives in a bucket.
|
|
5
|
+
*
|
|
6
|
+
* Reclaiming a source is only half of freeing a disk. The sealed archive stays
|
|
7
|
+
* behind, and on real transcripts it is about two thirds of what it replaced —
|
|
8
|
+
* measured, not assumed: a 1.6 GB Codex rollout compresses 1.5x. So a machine
|
|
9
|
+
* with 78 GB of sessions that archives and reclaims all of them keeps roughly
|
|
10
|
+
* 54 GB of archives and gets back about a quarter of what the product implied.
|
|
11
|
+
*
|
|
12
|
+
* Offloading finishes the job: once the bucket has a verified copy, the local
|
|
13
|
+
* one is redundant, and restoring downloads it back. Nothing is lost — but the
|
|
14
|
+
* archive stops being reachable while offline, which is a real trade and the
|
|
15
|
+
* reason this is opt-in and never automatic.
|
|
16
|
+
*
|
|
17
|
+
* The bar for removing a local archive is deliberately higher than for
|
|
18
|
+
* reclaiming a source: a source that was reclaimed can still be recovered from
|
|
19
|
+
* the trash, while an archive removed without a genuine remote copy is gone.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Just enough of a provider client to read an archive back. Injectable for the
|
|
23
|
+
* same reason `uploadArchive` takes one: the path that decides whether an
|
|
24
|
+
* offloaded archive can be recovered has to be testable without a bucket.
|
|
25
|
+
*/
|
|
26
|
+
export type FetchClient = {
|
|
27
|
+
download?: (lease: ReturnType<typeof createActiveLease>) => Promise<Buffer>;
|
|
28
|
+
};
|
|
29
|
+
export type OffloadCandidate = {
|
|
30
|
+
id: string;
|
|
31
|
+
agent: string;
|
|
32
|
+
path: string;
|
|
33
|
+
/** What removing the local copy would give back. */
|
|
34
|
+
localBytes: number;
|
|
35
|
+
provider: string;
|
|
36
|
+
bucket: string;
|
|
37
|
+
objectKey: string;
|
|
38
|
+
};
|
|
39
|
+
export type OffloadBlocked = {
|
|
40
|
+
id: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function offloadable(dataDir: string): Promise<{
|
|
44
|
+
candidates: OffloadCandidate[];
|
|
45
|
+
blocked: OffloadBlocked[];
|
|
46
|
+
freeableBytes: number;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Removes local archives that a bucket has a verified copy of.
|
|
50
|
+
*
|
|
51
|
+
* A dry run unless confirmed. The candidate list is recomputed here rather than
|
|
52
|
+
* accepted from a caller, so a page held open since yesterday cannot ask for
|
|
53
|
+
* the deletion of something that has since become the only copy.
|
|
54
|
+
*/
|
|
55
|
+
export declare function offloadArchives(dataDir: string, options?: {
|
|
56
|
+
confirm?: boolean;
|
|
57
|
+
client?: FetchClient;
|
|
58
|
+
only?: ReadonlySet<string>;
|
|
59
|
+
freshlyVerified?: ReadonlySet<string>;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
mode: "preview";
|
|
62
|
+
wouldOffload: OffloadCandidate[];
|
|
63
|
+
blocked: OffloadBlocked[];
|
|
64
|
+
freedBytes: number;
|
|
65
|
+
offloaded?: undefined;
|
|
66
|
+
failed?: undefined;
|
|
67
|
+
} | {
|
|
68
|
+
mode: "apply";
|
|
69
|
+
offloaded: OffloadCandidate[];
|
|
70
|
+
failed: {
|
|
71
|
+
id: string;
|
|
72
|
+
error: string;
|
|
73
|
+
}[];
|
|
74
|
+
blocked: OffloadBlocked[];
|
|
75
|
+
freedBytes: number;
|
|
76
|
+
wouldOffload?: undefined;
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Asks the storage, right now, whether it still holds this archive.
|
|
80
|
+
*
|
|
81
|
+
* `remote.verifiedAt` says the copy was proven once; between then and now is
|
|
82
|
+
* exactly where a bucket lifecycle rule or a fat-fingered console delete
|
|
83
|
+
* lives. This is the question a person asks before trusting the cloud copy
|
|
84
|
+
* enough to trash the original — so it goes to the provider, not to our own
|
|
85
|
+
* records, and it compares the size the provider reports against the size we
|
|
86
|
+
* recorded. It does not re-download the object: the byte-level proof already
|
|
87
|
+
* ran when the copy was verified, and runs again on any restore (AEAD refuses
|
|
88
|
+
* corrupt bytes). What this rules out is the copy being gone or truncated.
|
|
89
|
+
*/
|
|
90
|
+
export type CopyCheck = {
|
|
91
|
+
ok: boolean;
|
|
92
|
+
provider: string;
|
|
93
|
+
bytes: number;
|
|
94
|
+
checkedAt: string;
|
|
95
|
+
detail: string;
|
|
96
|
+
};
|
|
97
|
+
export declare function checkRemoteCopy(dataDir: string, archiveId: string, client?: FetchClient): Promise<CopyCheck>;
|
|
98
|
+
/**
|
|
99
|
+
* The bulk form of `checkRemoteCopy`. Managed rows share ONE control-plane
|
|
100
|
+
* listing instead of asking per archive — checking a 53-row vault is one call
|
|
101
|
+
* plus a HEAD per own-bucket object, not 53 round trips.
|
|
102
|
+
*/
|
|
103
|
+
export declare function checkRemoteCopyMany(dataDir: string, ids: readonly string[], client?: FetchClient): Promise<CopyCheck[]>;
|
|
104
|
+
export type Materialised = {
|
|
105
|
+
path: string;
|
|
106
|
+
release: () => Promise<void>;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Gives back a local path holding this archive's ciphertext, fetching it first
|
|
110
|
+
* if the local copy was offloaded.
|
|
111
|
+
*
|
|
112
|
+
* Restoring reads the object twice — once to hash it, once to decrypt it — so
|
|
113
|
+
* it needs a file, not a buffer. An offloaded archive is written to a temporary
|
|
114
|
+
* one and removed afterwards, which keeps the disk saving intact: the bytes are
|
|
115
|
+
* on this machine only for as long as the restore takes.
|
|
116
|
+
*/
|
|
117
|
+
export declare function materialiseArchive(dataDir: string, record: ArchiveRecord, client?: FetchClient): Promise<Materialised>;
|