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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much room archiving needs, and whether there is any.
|
|
3
|
+
*
|
|
4
|
+
* Archiving costs disk before it saves any. A transcript is gzipped to a
|
|
5
|
+
* scratch file and then sealed into a second one, while the original is still
|
|
6
|
+
* sitting there — so free space dips by roughly twice the compressed size
|
|
7
|
+
* before the source is eligible to be reclaimed, and it is only ever reclaimed
|
|
8
|
+
* later, by a separate decision.
|
|
9
|
+
*
|
|
10
|
+
* That matters because of who this is for. The people who install this have
|
|
11
|
+
* nearly full disks; that is the problem it exists to solve. A background
|
|
12
|
+
* process that fills the last few gigabytes while trying to free some is not a
|
|
13
|
+
* degraded experience, it is the failure the user was already afraid of.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Bytes to leave alone whatever happens, so the machine keeps working.
|
|
17
|
+
*
|
|
18
|
+
* 2 GB is the ceiling, not the rule. A flat 2 GB floor cut off exactly the
|
|
19
|
+
* people this exists for — someone with 1.5 GB free could not seal a 10 MB
|
|
20
|
+
* session that needed 7 MB. The reserve scales with the disk instead: 2% of
|
|
21
|
+
* the volume, never below 400 MB (where operating systems themselves start
|
|
22
|
+
* failing), never above 2 GB (where more head-room stops buying safety). A
|
|
23
|
+
* person who knows their machine can override it in Settings, down to the
|
|
24
|
+
* 200 MB hard floor.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DISK_RESERVE_BYTES: number;
|
|
27
|
+
export declare const MIN_RESERVE_BYTES: number;
|
|
28
|
+
/** The automatic reserve for a volume of `totalBytes`. Unknown → the old 2 GB. */
|
|
29
|
+
export declare function scaledReserve(totalBytes?: number): number;
|
|
30
|
+
/**
|
|
31
|
+
* The reserve this vault actually runs with: the Settings override when a
|
|
32
|
+
* person set one (clamped to the hard floor), the scaled automatic otherwise.
|
|
33
|
+
* Reads local-settings.json directly — a three-line JSON peek is not worth an
|
|
34
|
+
* import cycle with the API module that owns the file.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveReserveBytes(dataDir: string): Promise<number>;
|
|
37
|
+
/**
|
|
38
|
+
* The measured ratio, not the hoped-for one.
|
|
39
|
+
*
|
|
40
|
+
* `vault.ts` assumed agent transcripts compress about 2x. Measured on a real
|
|
41
|
+
* 1.6 GB Codex rollout it is 1.5x — the sealed copy is 68.8% of the source —
|
|
42
|
+
* because these files carry a lot of base64 and tool output that has already
|
|
43
|
+
* been compressed once. Planning space against 2x would under-reserve on every
|
|
44
|
+
* real file, so the number here is the one that was observed, rounded against
|
|
45
|
+
* our own favour.
|
|
46
|
+
*/
|
|
47
|
+
export declare const OBSERVED_COMPRESSION = 0.7;
|
|
48
|
+
/**
|
|
49
|
+
* What one archive of this size needs before it is safe to start.
|
|
50
|
+
*
|
|
51
|
+
* The gzip scratch and the sealed object both exist at once, so it is two
|
|
52
|
+
* compressed copies, plus the reserve. Incompressible input is the worst case
|
|
53
|
+
* and it is not rare enough to ignore, so the ratio is capped at 1: a file that
|
|
54
|
+
* does not shrink is planned for as if it will not.
|
|
55
|
+
*/
|
|
56
|
+
export declare function spaceNeededFor(sourceBytes: number, reserveBytes?: number): number;
|
|
57
|
+
/** Free bytes on the volume holding `path`, or undefined where that cannot be asked. */
|
|
58
|
+
export declare function freeBytes(path: string): Promise<number | undefined>;
|
|
59
|
+
/**
|
|
60
|
+
* Space that in-flight seals have claimed but not yet turned into files.
|
|
61
|
+
*
|
|
62
|
+
* `statfs` answers what is free *now*, and a seal spends its space over the next
|
|
63
|
+
* seconds or minutes. Two seals that each ask "is there room?" in that window
|
|
64
|
+
* both hear yes and jointly overfill — which fired live: a seal and a test
|
|
65
|
+
* suite ate the same headroom to literally 0 bytes free. The ledger closes the
|
|
66
|
+
* window inside one process: a seal claims its bytes before asking, and the
|
|
67
|
+
* verdict counts everyone else's claims against the measured free space.
|
|
68
|
+
*
|
|
69
|
+
* Per-process on purpose. Cross-process seals still race exactly as they did,
|
|
70
|
+
* but every concurrent seal this codebase runs — the daemon's parallel drain,
|
|
71
|
+
* the local API's drain — shares one process, so the process ledger covers the
|
|
72
|
+
* collisions that actually happened.
|
|
73
|
+
*/
|
|
74
|
+
export declare class Reservations {
|
|
75
|
+
private readonly held;
|
|
76
|
+
/** Claims `bytes` for `jobId`. Reserving again replaces the earlier claim. */
|
|
77
|
+
reserve(jobId: string, bytes: number): void;
|
|
78
|
+
release(jobId: string): void;
|
|
79
|
+
/** Everything currently claimed, in bytes. */
|
|
80
|
+
get total(): number;
|
|
81
|
+
/**
|
|
82
|
+
* What everyone *else* has claimed. A seal asking about its own job must not
|
|
83
|
+
* have its own claim counted against it, or reserving first would make every
|
|
84
|
+
* verdict a refusal.
|
|
85
|
+
*/
|
|
86
|
+
otherThan(jobId?: string): number;
|
|
87
|
+
get size(): number;
|
|
88
|
+
}
|
|
89
|
+
/** The one ledger every seal in this process coordinates through by default. */
|
|
90
|
+
export declare const sealReservations: Reservations;
|
|
91
|
+
export type SpaceVerdict = {
|
|
92
|
+
ok: boolean;
|
|
93
|
+
free?: number;
|
|
94
|
+
needed: number;
|
|
95
|
+
detail: string;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Whether there is room to archive this file right now.
|
|
99
|
+
*
|
|
100
|
+
* An unanswerable `statfs` is treated as room. That is deliberate and it is the
|
|
101
|
+
* less obvious direction: refusing to work because the free space could not be
|
|
102
|
+
* measured would turn an unusual filesystem into a silent stoppage, and the
|
|
103
|
+
* archive path already fails loudly and safely on ENOSPC. Guessing wrong here
|
|
104
|
+
* costs a failed archive; guessing wrong the other way costs every archive.
|
|
105
|
+
*
|
|
106
|
+
* With a `reservations` ledger, the verdict is taken against the free space
|
|
107
|
+
* that will remain once every *other* in-flight seal lands: measured free minus
|
|
108
|
+
* the ledger's claims other than `jobId`'s own.
|
|
109
|
+
*/
|
|
110
|
+
export declare function roomToArchive(dataDir: string, sourceBytes: number, reserveBytes?: number, reservations?: Reservations, jobId?: string): Promise<SpaceVerdict>;
|
package/dist/src/disk.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { statfs } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* How much room archiving needs, and whether there is any.
|
|
4
|
+
*
|
|
5
|
+
* Archiving costs disk before it saves any. A transcript is gzipped to a
|
|
6
|
+
* scratch file and then sealed into a second one, while the original is still
|
|
7
|
+
* sitting there — so free space dips by roughly twice the compressed size
|
|
8
|
+
* before the source is eligible to be reclaimed, and it is only ever reclaimed
|
|
9
|
+
* later, by a separate decision.
|
|
10
|
+
*
|
|
11
|
+
* That matters because of who this is for. The people who install this have
|
|
12
|
+
* nearly full disks; that is the problem it exists to solve. A background
|
|
13
|
+
* process that fills the last few gigabytes while trying to free some is not a
|
|
14
|
+
* degraded experience, it is the failure the user was already afraid of.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Bytes to leave alone whatever happens, so the machine keeps working.
|
|
18
|
+
*
|
|
19
|
+
* 2 GB is the ceiling, not the rule. A flat 2 GB floor cut off exactly the
|
|
20
|
+
* people this exists for — someone with 1.5 GB free could not seal a 10 MB
|
|
21
|
+
* session that needed 7 MB. The reserve scales with the disk instead: 2% of
|
|
22
|
+
* the volume, never below 400 MB (where operating systems themselves start
|
|
23
|
+
* failing), never above 2 GB (where more head-room stops buying safety). A
|
|
24
|
+
* person who knows their machine can override it in Settings, down to the
|
|
25
|
+
* 200 MB hard floor.
|
|
26
|
+
*/
|
|
27
|
+
export const DISK_RESERVE_BYTES = 2 * 1024 ** 3;
|
|
28
|
+
export const MIN_RESERVE_BYTES = 200 * 1024 ** 2;
|
|
29
|
+
/** The automatic reserve for a volume of `totalBytes`. Unknown → the old 2 GB. */
|
|
30
|
+
export function scaledReserve(totalBytes) {
|
|
31
|
+
if (!Number.isFinite(totalBytes) || !totalBytes || totalBytes <= 0)
|
|
32
|
+
return DISK_RESERVE_BYTES;
|
|
33
|
+
return Math.min(DISK_RESERVE_BYTES, Math.max(400 * 1024 ** 2, Math.ceil(totalBytes * 0.02)));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The reserve this vault actually runs with: the Settings override when a
|
|
37
|
+
* person set one (clamped to the hard floor), the scaled automatic otherwise.
|
|
38
|
+
* Reads local-settings.json directly — a three-line JSON peek is not worth an
|
|
39
|
+
* import cycle with the API module that owns the file.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveReserveBytes(dataDir) {
|
|
42
|
+
try {
|
|
43
|
+
const { readFile } = await import("node:fs/promises");
|
|
44
|
+
const { join } = await import("node:path");
|
|
45
|
+
const saved = JSON.parse(await readFile(join(dataDir, "local-settings.json"), "utf8"));
|
|
46
|
+
const mb = saved?.diskReserveMb;
|
|
47
|
+
if (typeof mb === "number" && Number.isFinite(mb))
|
|
48
|
+
return Math.max(MIN_RESERVE_BYTES, Math.round(mb * 1024 ** 2));
|
|
49
|
+
}
|
|
50
|
+
catch { /* no settings file, or no override: automatic */ }
|
|
51
|
+
try {
|
|
52
|
+
const info = await statfs(dataDir);
|
|
53
|
+
return scaledReserve(Number(info.blocks) * Number(info.bsize));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return DISK_RESERVE_BYTES;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The measured ratio, not the hoped-for one.
|
|
61
|
+
*
|
|
62
|
+
* `vault.ts` assumed agent transcripts compress about 2x. Measured on a real
|
|
63
|
+
* 1.6 GB Codex rollout it is 1.5x — the sealed copy is 68.8% of the source —
|
|
64
|
+
* because these files carry a lot of base64 and tool output that has already
|
|
65
|
+
* been compressed once. Planning space against 2x would under-reserve on every
|
|
66
|
+
* real file, so the number here is the one that was observed, rounded against
|
|
67
|
+
* our own favour.
|
|
68
|
+
*/
|
|
69
|
+
export const OBSERVED_COMPRESSION = 0.7;
|
|
70
|
+
/**
|
|
71
|
+
* What one archive of this size needs before it is safe to start.
|
|
72
|
+
*
|
|
73
|
+
* The gzip scratch and the sealed object both exist at once, so it is two
|
|
74
|
+
* compressed copies, plus the reserve. Incompressible input is the worst case
|
|
75
|
+
* and it is not rare enough to ignore, so the ratio is capped at 1: a file that
|
|
76
|
+
* does not shrink is planned for as if it will not.
|
|
77
|
+
*/
|
|
78
|
+
export function spaceNeededFor(sourceBytes, reserveBytes = DISK_RESERVE_BYTES) {
|
|
79
|
+
// One ciphertext, roughly the compressed size, plus the standing reserve.
|
|
80
|
+
// The ×2 that used to live here paid for the legacy layout's staged gzip
|
|
81
|
+
// body coexisting with the ciphertext; gzip-chunk seals in a single pass
|
|
82
|
+
// and the staging is gone. A 5 GB session now asks for ~5.5 GB free, not 9.
|
|
83
|
+
return Math.ceil(sourceBytes * Math.min(1, OBSERVED_COMPRESSION)) + reserveBytes;
|
|
84
|
+
}
|
|
85
|
+
/** Free bytes on the volume holding `path`, or undefined where that cannot be asked. */
|
|
86
|
+
export async function freeBytes(path) {
|
|
87
|
+
try {
|
|
88
|
+
const info = await statfs(path);
|
|
89
|
+
return Number(info.bavail) * Number(info.bsize);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Space that in-flight seals have claimed but not yet turned into files.
|
|
97
|
+
*
|
|
98
|
+
* `statfs` answers what is free *now*, and a seal spends its space over the next
|
|
99
|
+
* seconds or minutes. Two seals that each ask "is there room?" in that window
|
|
100
|
+
* both hear yes and jointly overfill — which fired live: a seal and a test
|
|
101
|
+
* suite ate the same headroom to literally 0 bytes free. The ledger closes the
|
|
102
|
+
* window inside one process: a seal claims its bytes before asking, and the
|
|
103
|
+
* verdict counts everyone else's claims against the measured free space.
|
|
104
|
+
*
|
|
105
|
+
* Per-process on purpose. Cross-process seals still race exactly as they did,
|
|
106
|
+
* but every concurrent seal this codebase runs — the daemon's parallel drain,
|
|
107
|
+
* the local API's drain — shares one process, so the process ledger covers the
|
|
108
|
+
* collisions that actually happened.
|
|
109
|
+
*/
|
|
110
|
+
export class Reservations {
|
|
111
|
+
held = new Map();
|
|
112
|
+
/** Claims `bytes` for `jobId`. Reserving again replaces the earlier claim. */
|
|
113
|
+
reserve(jobId, bytes) {
|
|
114
|
+
this.held.set(jobId, Math.max(0, Math.ceil(bytes)));
|
|
115
|
+
}
|
|
116
|
+
release(jobId) {
|
|
117
|
+
this.held.delete(jobId);
|
|
118
|
+
}
|
|
119
|
+
/** Everything currently claimed, in bytes. */
|
|
120
|
+
get total() {
|
|
121
|
+
return this.otherThan();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What everyone *else* has claimed. A seal asking about its own job must not
|
|
125
|
+
* have its own claim counted against it, or reserving first would make every
|
|
126
|
+
* verdict a refusal.
|
|
127
|
+
*/
|
|
128
|
+
otherThan(jobId) {
|
|
129
|
+
let total = 0;
|
|
130
|
+
for (const [id, bytes] of this.held)
|
|
131
|
+
if (id !== jobId)
|
|
132
|
+
total += bytes;
|
|
133
|
+
return total;
|
|
134
|
+
}
|
|
135
|
+
get size() {
|
|
136
|
+
return this.held.size;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** The one ledger every seal in this process coordinates through by default. */
|
|
140
|
+
export const sealReservations = new Reservations();
|
|
141
|
+
/**
|
|
142
|
+
* Whether there is room to archive this file right now.
|
|
143
|
+
*
|
|
144
|
+
* An unanswerable `statfs` is treated as room. That is deliberate and it is the
|
|
145
|
+
* less obvious direction: refusing to work because the free space could not be
|
|
146
|
+
* measured would turn an unusual filesystem into a silent stoppage, and the
|
|
147
|
+
* archive path already fails loudly and safely on ENOSPC. Guessing wrong here
|
|
148
|
+
* costs a failed archive; guessing wrong the other way costs every archive.
|
|
149
|
+
*
|
|
150
|
+
* With a `reservations` ledger, the verdict is taken against the free space
|
|
151
|
+
* that will remain once every *other* in-flight seal lands: measured free minus
|
|
152
|
+
* the ledger's claims other than `jobId`'s own.
|
|
153
|
+
*/
|
|
154
|
+
export async function roomToArchive(dataDir, sourceBytes, reserveBytes, reservations, jobId) {
|
|
155
|
+
const reserve = reserveBytes ?? await resolveReserveBytes(dataDir);
|
|
156
|
+
const needed = spaceNeededFor(sourceBytes, reserve);
|
|
157
|
+
const measured = await freeBytes(dataDir);
|
|
158
|
+
if (measured === undefined)
|
|
159
|
+
return { ok: true, needed, detail: "free space could not be measured, so this was allowed to proceed" };
|
|
160
|
+
const held = reservations?.otherThan(jobId) ?? 0;
|
|
161
|
+
const free = Math.max(0, measured - held);
|
|
162
|
+
const gb = (value) => `${(value / 1024 ** 3).toFixed(1)} GB`;
|
|
163
|
+
if (free >= needed)
|
|
164
|
+
return { ok: true, free, needed, detail: `${gb(free)} free, ${gb(needed)} needed` };
|
|
165
|
+
return {
|
|
166
|
+
ok: false, free, needed,
|
|
167
|
+
detail: `needs about ${gb(needed)} free to seal safely (${gb(reserve)} of that is the reserve that keeps this machine itself alive — adjustable in Settings) and there is ${gb(free)}${held > 0 ? ` after ${gb(held)} already claimed by seals in flight` : ""}; left queued rather than filling the disk`
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type CheckStatus = "pass" | "warn" | "fail";
|
|
2
|
+
export type Check = {
|
|
3
|
+
name: string;
|
|
4
|
+
status: CheckStatus;
|
|
5
|
+
detail: string;
|
|
6
|
+
};
|
|
7
|
+
export type DoctorReport = {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
checks: Check[];
|
|
10
|
+
};
|
|
11
|
+
export declare function runDoctor(dataDir: string, env?: NodeJS.ProcessEnv): Promise<DoctorReport>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { detectAgents } from "./adapters.js";
|
|
5
|
+
import { isVaultlineError } from "./errors.js";
|
|
6
|
+
import { localApiTokenPath } from "./paths.js";
|
|
7
|
+
import { ArchiveQueue } from "./queue.js";
|
|
8
|
+
import { signerEnabled } from "./control-plane.js";
|
|
9
|
+
import { quotaMessage, quotaState } from "./packages.js";
|
|
10
|
+
import { loadProviderCredentials, resolveRecoveryPhrase } from "./secrets.js";
|
|
11
|
+
import { ENVELOPE_VERSION, SUITES } from "../packages/vaultline-crypto/src/index.js";
|
|
12
|
+
import { readConfig } from "./vault.js";
|
|
13
|
+
async function writable(directory) {
|
|
14
|
+
const probe = join(directory, `.vaultline-write-probe-${randomUUID()}`);
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
17
|
+
await writeFile(probe, "");
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await rm(probe, { force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A read-mostly environment report. It writes only a probe file it removes, and
|
|
29
|
+
* it never reads transcript contents, prints a recovery phrase, or shows the API token.
|
|
30
|
+
*/
|
|
31
|
+
/** Whether this machine is signed in to managed storage, without failing if it is not. */
|
|
32
|
+
async function managedAccount(dataDir) {
|
|
33
|
+
try {
|
|
34
|
+
const { accountStatus } = await import("./cloud.js");
|
|
35
|
+
const account = await accountStatus(dataDir);
|
|
36
|
+
return account?.email ? { email: account.email, plan: account.plan, quotaBytes: account.quota_bytes ?? 0, usedBytes: account.used_bytes ?? 0 } : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A customer who fills their quota used to find out from a bare 402 on their
|
|
44
|
+
* next upload — no warning here, no number anywhere. `quota_bytes` and
|
|
45
|
+
* `used_bytes` were sitting on the account the whole time; this is doctor's
|
|
46
|
+
* side of surfacing them, sharing its wording with `cloud status`, `status`,
|
|
47
|
+
* and the rewritten 402 so all four agree.
|
|
48
|
+
*/
|
|
49
|
+
function quotaCheck(managed) {
|
|
50
|
+
const state = quotaState(managed.usedBytes, managed.quotaBytes);
|
|
51
|
+
const status = state.level === "over" ? "fail" : state.level === "ok" ? "pass" : "warn";
|
|
52
|
+
return { name: "quota", status, detail: quotaMessage(state) };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The panel warns that losing this machine loses every archive on it unless a
|
|
56
|
+
* sealed copy of the key is stored. Nothing on the machine ever checked, so the
|
|
57
|
+
* warning and the reality could disagree indefinitely.
|
|
58
|
+
*/
|
|
59
|
+
async function sealedCopyCheck(dataDir, signedIn) {
|
|
60
|
+
if (!signedIn)
|
|
61
|
+
return { name: "sealed-key-copy", status: "warn", detail: "Not signed in, so no sealed copy of your key is stored" };
|
|
62
|
+
try {
|
|
63
|
+
const { hasSealedCopy } = await import("./cloud.js");
|
|
64
|
+
return await hasSealedCopy(dataDir)
|
|
65
|
+
? { name: "sealed-key-copy", status: "pass", detail: "A sealed copy of your key is stored with your account" }
|
|
66
|
+
: { name: "sealed-key-copy", status: "warn", detail: "Your key has no sealed backup yet. If this machine is lost, the archives on it cannot be opened by anyone, including us. Store one from the account panel." };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { name: "sealed-key-copy", status: "warn", detail: `Could not check for a sealed copy: ${error instanceof Error ? error.message.split("\n")[0] : "unknown"}` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function runDoctor(dataDir, env = process.env) {
|
|
73
|
+
const checks = [];
|
|
74
|
+
const config = await readConfig(dataDir).catch((error) => {
|
|
75
|
+
// This renders in the Health tab, where a raw "config_unsupported_version:"
|
|
76
|
+
// slug or a bare "unreadable" reads, to someone told there is no reset, as
|
|
77
|
+
// "your vault is gone." Say what is and is not affected, and never a code.
|
|
78
|
+
const detail = isVaultlineError(error) && error.code === "vault_not_initialized"
|
|
79
|
+
? "No vault has been set up on this machine yet. Open Sealkeep's setup page to create one."
|
|
80
|
+
: "Sealkeep can't read this vault's settings file. Your sealed archives are separate files and are not affected — don't create a new vault; restore this machine's vault folder from a backup, or contact support.";
|
|
81
|
+
checks.push({ name: "config", status: "fail", detail });
|
|
82
|
+
return null;
|
|
83
|
+
});
|
|
84
|
+
if (config) {
|
|
85
|
+
checks.push({ name: "config", status: "pass", detail: `Vault ${config.vaultId} (config v${config.version})` });
|
|
86
|
+
// A standing warning, like the crypto row: nothing about a healthy plain
|
|
87
|
+
// vault makes this go away, because the exposure is the choice itself.
|
|
88
|
+
if (config.storageMode === "plain") {
|
|
89
|
+
checks.push({ name: "storage-mode", status: "warn", detail: "Archives in this vault are stored unencrypted, by your choice at setup. Anyone who can read the storage can read the sessions." });
|
|
90
|
+
}
|
|
91
|
+
checks.push(await writable(config.storage.root)
|
|
92
|
+
? { name: "archive-store", status: "pass", detail: `Writable: ${config.storage.root}` }
|
|
93
|
+
: { name: "archive-store", status: "fail", detail: `Not writable: ${config.storage.root}` });
|
|
94
|
+
// A machine on the managed tier has no remoteStorage of its own — the
|
|
95
|
+
// bucket is ours. Reporting "archives stay on this device" to someone who
|
|
96
|
+
// has been pushing to managed storage all week is worse than saying
|
|
97
|
+
// nothing: it is a health check telling them a comforting falsehood.
|
|
98
|
+
if (config.remoteStorage) {
|
|
99
|
+
checks.push({ name: "remote-storage", status: "pass", detail: `Target ${config.remoteStorage.provider}://${config.remoteStorage.bucket}/${config.remoteStorage.prefix}` });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const managed = await managedAccount(dataDir);
|
|
103
|
+
checks.push(managed
|
|
104
|
+
// The cloud token lives in this machine's keychain, not in the vault,
|
|
105
|
+
// so being signed in says archives from here *can* go to managed
|
|
106
|
+
// storage — not that any have. Say the weaker, true thing.
|
|
107
|
+
? { name: "remote-storage", status: "pass", detail: `Signed in to managed storage as ${managed.email} (${managed.plan})` }
|
|
108
|
+
: { name: "remote-storage", status: "warn", detail: "No remote target configured; archives stay on this device" });
|
|
109
|
+
checks.push(await sealedCopyCheck(dataDir, managed !== null));
|
|
110
|
+
// A zero quota is a plan with no managed entitlement at all — nothing to warn about.
|
|
111
|
+
if (managed && managed.quotaBytes > 0)
|
|
112
|
+
checks.push(quotaCheck(managed));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const stats = await new ArchiveQueue(dataDir).stats();
|
|
116
|
+
checks.push(stats.failed > 0
|
|
117
|
+
? { name: "queue", status: "warn", detail: `${stats.failed} session${stats.failed === 1 ? "" : "s"} could not be archived. See the Queue tab for why, and retry from there.` }
|
|
118
|
+
: { name: "queue", status: "pass", detail: `${stats.ready} ready, ${stats.leased} in flight, ${stats.done} archived` });
|
|
119
|
+
const agents = await detectAgents();
|
|
120
|
+
const detected = agents.filter((agent) => agent.detected).map((agent) => agent.agent);
|
|
121
|
+
checks.push(detected.length > 0
|
|
122
|
+
? { name: "agents", status: "pass", detail: `Detected: ${detected.join(", ")}` }
|
|
123
|
+
: { name: "agents", status: "warn", detail: "No supported agent directory found on this machine" });
|
|
124
|
+
// The failure Paul hit in the wild: an agent's own catalog still points at
|
|
125
|
+
// a session file that is gone — reclaimed by us, or deleted by anything —
|
|
126
|
+
// and "resume" breaks with no hint that the bytes are one command away.
|
|
127
|
+
// This check counts archived sessions whose source file is missing and
|
|
128
|
+
// hands over the exact command that puts each back where resume expects it.
|
|
129
|
+
try {
|
|
130
|
+
const { stat } = await import("node:fs/promises");
|
|
131
|
+
const { listArchives } = await import("./vault.js");
|
|
132
|
+
const records = (await listArchives(dataDir)).filter((record) => record.version === 2);
|
|
133
|
+
const missing = [];
|
|
134
|
+
for (const record of records) {
|
|
135
|
+
const there = await stat(record.source.path).then((entry) => entry.isFile()).catch(() => false);
|
|
136
|
+
if (!there)
|
|
137
|
+
missing.push(record.source.path.split("/").pop() ?? record.id);
|
|
138
|
+
}
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
checks.push({
|
|
141
|
+
name: "resumable-history", status: "pass",
|
|
142
|
+
detail: `${missing.length} archived session${missing.length === 1 ? " file is" : " files are"} no longer on disk (reclaimed or removed). An agent trying to resume one will say the file is missing — \`sealkeep recover <that filename>\` puts it back exactly where resume expects it. First: ${missing[0]}`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else if (records.length > 0) {
|
|
146
|
+
checks.push({ name: "resumable-history", status: "pass", detail: "Every archived session's file is still on disk; agent resume needs nothing from us" });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { /* an unreadable archive store already failed its own check above */ }
|
|
150
|
+
// What search can and cannot see, without opening anything: the coverage
|
|
151
|
+
// sidecar lists indexed archive ids, and the difference against the record
|
|
152
|
+
// set is precisely the work `sealkeep index build` would do. Paul's ask,
|
|
153
|
+
// verbatim: "i hope we are tracking whats tracked and indexed".
|
|
154
|
+
try {
|
|
155
|
+
const { indexCoverage } = await import("./search.js");
|
|
156
|
+
const coverage = await indexCoverage(dataDir);
|
|
157
|
+
if (coverage.total === 0) {
|
|
158
|
+
// nothing archived yet — nothing to say
|
|
159
|
+
}
|
|
160
|
+
else if (coverage.missing.length === 0) {
|
|
161
|
+
checks.push({ name: "search-index", status: "pass", detail: `All ${coverage.total} archives are in the content search index${coverage.builtAt ? ` (updated ${coverage.builtAt.slice(0, 10)})` : ""}.` });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
checks.push({
|
|
165
|
+
name: "search-index", status: "pass",
|
|
166
|
+
detail: `${coverage.indexed} of ${coverage.total} archives are searchable; ${coverage.missing.length} not yet indexed (new seals index themselves — \`sealkeep index build\` adds the rest). First missing: ${coverage.missing[0].path.split("/").pop() ?? coverage.missing[0].id}`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch { /* coverage is advisory; a broken sidecar must not fail doctor */ }
|
|
171
|
+
// Where the daemon actually looks: an explicit env var, or the phrase the
|
|
172
|
+
// wizard put in this machine's keystore. This used to test only the env var,
|
|
173
|
+
// so a machine set up correctly through the window — phrase in the keystore,
|
|
174
|
+
// sealing away happily — was told on its Health tab that its phrase was
|
|
175
|
+
// missing and named an environment variable as the cure.
|
|
176
|
+
const phraseAvailable = config
|
|
177
|
+
? await resolveRecoveryPhrase(dataDir, config.vaultId, undefined, env).then((phrase) => Boolean(phrase)).catch(() => false)
|
|
178
|
+
: Boolean(env.VAULTLINE_RECOVERY_PHRASE);
|
|
179
|
+
checks.push(phraseAvailable
|
|
180
|
+
? { name: "recovery-phrase", status: "pass", detail: "This machine can unlock the vault on its own, so archiving runs unattended." }
|
|
181
|
+
: { name: "recovery-phrase", status: "warn", detail: "This machine doesn't hold your recovery phrase, so it can't archive on its own. Add it under Settings › Unlocking to let archiving run unattended. Your vault and archives are unaffected." });
|
|
182
|
+
const tokenMode = await stat(localApiTokenPath(dataDir)).then((info) => info.mode & 0o777).catch(() => null);
|
|
183
|
+
checks.push(tokenMode === null
|
|
184
|
+
? { name: "local-api-token", status: "warn", detail: "No local API token yet; it is created when `sealkeep api` first starts" }
|
|
185
|
+
: tokenMode === 0o600
|
|
186
|
+
? { name: "local-api-token", status: "pass", detail: "Local API token is owner-only" }
|
|
187
|
+
: { name: "local-api-token", status: "fail", detail: `Local API token mode is ${tokenMode.toString(8)}; it must be 600` });
|
|
188
|
+
const signerOn = signerEnabled(env);
|
|
189
|
+
const credentials = config ? await loadProviderCredentials(dataDir, config.vaultId).then(() => true).catch(() => false) : false;
|
|
190
|
+
checks.push(!signerOn
|
|
191
|
+
? { name: "signer", status: "warn", detail: "New archives aren't being uploaded from this machine right now. Anything already shown as verified in your storage is safely there. Turn on automatic archiving in Settings to resume uploads." }
|
|
192
|
+
: credentials
|
|
193
|
+
? { name: "signer", status: "pass", detail: "Signer enabled and provider credentials are available from the secret store" }
|
|
194
|
+
: { name: "signer", status: "warn", detail: "Uploads are on, but this machine has no key for the bucket. Add the bucket's credentials on the storage step of setup." });
|
|
195
|
+
// Deliberately a standing warning: the envelope is unaudited, and no local check can change that.
|
|
196
|
+
checks.push({ name: "crypto", status: "warn", detail: `Archive envelope v${ENVELOPE_VERSION} (${SUITES[0]}); implemented against published primitives and not independently audited` });
|
|
197
|
+
return { ok: checks.every((check) => check.status !== "fail"), checks };
|
|
198
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type SyncRules } from "./sync-rules.js";
|
|
2
|
+
import { type RetentionSettings } from "./retention.js";
|
|
3
|
+
export type EnrollOptions = {
|
|
4
|
+
/** The short-lived code shown in the account panel under Devices > Add device. */
|
|
5
|
+
code: string;
|
|
6
|
+
/** How this machine will show up in the device list. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Injectable so enrollment is testable without a live server; defaults to the global fetch. */
|
|
9
|
+
transport?: typeof fetch;
|
|
10
|
+
};
|
|
11
|
+
export type EnrollResult = {
|
|
12
|
+
deviceId: string;
|
|
13
|
+
account: {
|
|
14
|
+
plan: string;
|
|
15
|
+
quotaBytes: number;
|
|
16
|
+
usedBytes: number;
|
|
17
|
+
};
|
|
18
|
+
/** The rules just cached locally, so a caller can report what is now enforced offline. */
|
|
19
|
+
settings: {
|
|
20
|
+
sync: SyncRules;
|
|
21
|
+
retention: RetentionSettings;
|
|
22
|
+
};
|
|
23
|
+
/** False when enrollment itself succeeded but saving the settings locally did not; see cacheWarning. */
|
|
24
|
+
settingsCached: boolean;
|
|
25
|
+
cacheWarning?: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function enroll(dataDir: string, options: EnrollOptions): Promise<EnrollResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { fail } from "./errors.js";
|
|
2
|
+
import { chooseBackend } from "./secrets.js";
|
|
3
|
+
import { readConfig } from "./vault.js";
|
|
4
|
+
import { DEFAULT_CLOUD_URL, tokenRefFor } from "./cloud.js";
|
|
5
|
+
import { cacheSyncRules, cachedSyncRules, DEFAULT_SYNC_RULES } from "./sync-rules.js";
|
|
6
|
+
import { setRetentionPolicy, retentionSettings, DEFAULT_RETENTION } from "./retention.js";
|
|
7
|
+
/**
|
|
8
|
+
* Device enrollment: the client half of `sealkeep enroll ABCD-1234`.
|
|
9
|
+
*
|
|
10
|
+
* Today's setup is three separate commands — create a vault, sign in to
|
|
11
|
+
* Cloud, configure storage — each a place to stop half-configured. This
|
|
12
|
+
* collapses it to one: trade a short-lived code (minted in the account
|
|
13
|
+
* panel) for a device token, and pull down the account's sync and
|
|
14
|
+
* retention settings in the same round trip so they are enforceable
|
|
15
|
+
* offline before the first archive is ever queued.
|
|
16
|
+
*
|
|
17
|
+
* The server side of this contract does not exist yet, so `transport` is
|
|
18
|
+
* injectable — this module has to be testable against a fake today and
|
|
19
|
+
* work unchanged against the real thing later.
|
|
20
|
+
*/
|
|
21
|
+
const cloudUrl = (env = process.env) => (env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
|
22
|
+
/**
|
|
23
|
+
* The token location comes from cloud.ts rather than being restated here.
|
|
24
|
+
*
|
|
25
|
+
* It has to match exactly: `cloudToken()` and everything built on it —
|
|
26
|
+
* `accountStatus`, `pushArchive`, `resolveSyncRules` — look the token up by
|
|
27
|
+
* that key, so a divergence would make `enroll` report success while every
|
|
28
|
+
* call after it said "Not signed in to Sealkeep Cloud". A second copy that
|
|
29
|
+
* merely *looked* right is how `access_key_id` survived the rename to `akid`
|
|
30
|
+
* in two vending scripts and both tests.
|
|
31
|
+
*/
|
|
32
|
+
const tokenRef = tokenRefFor;
|
|
33
|
+
const safeJson = (text) => { try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { error: text.slice(0, 200) };
|
|
38
|
+
} };
|
|
39
|
+
/** What the account panel shows as this machine's platform. */
|
|
40
|
+
function platformLabel() {
|
|
41
|
+
switch (process.platform) {
|
|
42
|
+
case "darwin": return "macOS";
|
|
43
|
+
case "win32": return "Windows";
|
|
44
|
+
case "linux": return "Linux";
|
|
45
|
+
default: return process.platform;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The backend for this endpoint doesn't exist yet, so the exact status for
|
|
50
|
+
* "the code didn't work" isn't settled. Whichever of these a bad, expired,
|
|
51
|
+
* or already-redeemed code comes back as, the client's answer is the
|
|
52
|
+
* same — the code is the problem, not the connection or the vault — so
|
|
53
|
+
* every one of them is treated identically and the server's own message
|
|
54
|
+
* carries the specifics.
|
|
55
|
+
*/
|
|
56
|
+
const CODE_REJECTED_STATUSES = new Set([400, 401, 404, 409, 410]);
|
|
57
|
+
export async function enroll(dataDir, options) {
|
|
58
|
+
const code = options.code?.trim() ?? "";
|
|
59
|
+
const label = options.label?.trim() ?? "";
|
|
60
|
+
if (!code)
|
|
61
|
+
fail("invalid_argument", "An enrollment code is required. Find it in the account panel under Devices > Add device.");
|
|
62
|
+
if (!label)
|
|
63
|
+
fail("invalid_argument", `A device label is required, e.g. --label "work-laptop", so devices can be told apart later.`);
|
|
64
|
+
// No vault means nowhere to keep the token or the settings it unlocks,
|
|
65
|
+
// and the code is single-use — better to fail before spending it than
|
|
66
|
+
// after. readConfig's own "vault_not_initialized" message already names
|
|
67
|
+
// the fix, so it is left to propagate rather than rewritten here.
|
|
68
|
+
const { vaultId } = await readConfig(dataDir);
|
|
69
|
+
const backend = await chooseBackend(dataDir);
|
|
70
|
+
const ref = tokenRef(vaultId);
|
|
71
|
+
if (await backend.get(ref)) {
|
|
72
|
+
fail("forbidden", "This vault is already enrolled with Sealkeep Cloud. Run `sealkeep cloud logout` first if you want to enroll a different account or device.");
|
|
73
|
+
}
|
|
74
|
+
const transport = options.transport ?? fetch;
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await transport(`${cloudUrl()}/v1/cloud/devices/enroll`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "content-type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ code, label, platform: platformLabel() })
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
fail("internal", `Sealkeep Cloud is unreachable: ${error instanceof Error ? error.message : "network error"}. Check your connection and run \`sealkeep enroll ${code}\` again.`);
|
|
85
|
+
}
|
|
86
|
+
const text = await response.text();
|
|
87
|
+
const body = text ? safeJson(text) : null;
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const message = body?.error ?? `${response.status} ${response.statusText}`;
|
|
90
|
+
if (CODE_REJECTED_STATUSES.has(response.status)) {
|
|
91
|
+
fail("unauthorized", `Sealkeep Cloud rejected code ${code}: ${message}. Get a fresh code from the account panel (Devices > Add device) and run \`sealkeep enroll <code>\` again.`);
|
|
92
|
+
}
|
|
93
|
+
fail("internal", `Sealkeep Cloud returned ${response.status} while enrolling: ${message}`);
|
|
94
|
+
}
|
|
95
|
+
const parsed = body;
|
|
96
|
+
if (!parsed || !parsed.device_id || !parsed.token) {
|
|
97
|
+
fail("internal", `Sealkeep Cloud accepted code ${code} but did not return a device token. Nothing was stored; run \`sealkeep enroll ${code}\` again.`);
|
|
98
|
+
}
|
|
99
|
+
const enrolled = parsed;
|
|
100
|
+
// Store the token before anything else. It is the one part of this that
|
|
101
|
+
// cannot be recomputed locally, and every later cloud.ts call —
|
|
102
|
+
// accountStatus, pushArchive, resolveSyncRules — reads it back from
|
|
103
|
+
// exactly this service/account pair.
|
|
104
|
+
await backend.set(ref, enrolled.token);
|
|
105
|
+
let settingsCached = true;
|
|
106
|
+
let cacheWarning;
|
|
107
|
+
let sync;
|
|
108
|
+
let retention;
|
|
109
|
+
try {
|
|
110
|
+
sync = await cacheSyncRules(dataDir, enrolled.settings ?? {});
|
|
111
|
+
const retentionIn = enrolled.settings?.retention;
|
|
112
|
+
const nextConfig = await setRetentionPolicy(dataDir, {
|
|
113
|
+
...(retentionIn?.policy ? { policy: retentionIn.policy } : {}),
|
|
114
|
+
...(retentionIn?.older_than_days !== undefined ? { olderThanDays: retentionIn.older_than_days } : {}),
|
|
115
|
+
...(retentionIn?.grace_days !== undefined ? { graceDays: retentionIn.grace_days } : {})
|
|
116
|
+
});
|
|
117
|
+
retention = retentionSettings(nextConfig);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// The device is enrolled either way: the token above is already
|
|
121
|
+
// stored, and retrying enroll() would now (correctly) report
|
|
122
|
+
// "already enrolled" rather than fix this. So a bad settings payload
|
|
123
|
+
// degrades to a warning instead of unwinding the enrollment.
|
|
124
|
+
settingsCached = false;
|
|
125
|
+
cacheWarning = `Enrolled, but could not save the account's sync and retention settings locally: ${error instanceof Error ? error.message : "unknown error"}. Offline runs use the last cached or default rules until this is retried — run \`sealkeep cloud status\` to fetch them again.`;
|
|
126
|
+
sync = (await cachedSyncRules(dataDir)) ?? DEFAULT_SYNC_RULES;
|
|
127
|
+
retention = DEFAULT_RETENTION;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
deviceId: enrolled.device_id,
|
|
131
|
+
account: { plan: enrolled.account.plan, quotaBytes: enrolled.account.quota_bytes, usedBytes: enrolled.account.used_bytes },
|
|
132
|
+
settings: { sync, retention },
|
|
133
|
+
settingsCached,
|
|
134
|
+
...(cacheWarning ? { cacheWarning } : {})
|
|
135
|
+
};
|
|
136
|
+
}
|