sealkeep 0.5.2 → 0.6.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.
@@ -10,6 +10,20 @@ import { isV2 } from "./types.js";
10
10
  const indexPath = (dataDir) => join(dataDir, "index", "content-index.vlindex");
11
11
  const envelopePath = (dataDir) => join(dataDir, "index", "content-index.json");
12
12
  const coveragePath = (dataDir) => join(dataDir, "index", "coverage.json");
13
+ /**
14
+ * The ids THIS machine has indexed, from its own unsealed sidecar. Used to
15
+ * decide what this machine is entitled to forget: entries it never wrote
16
+ * belong to another machine of the vault and are not ours to delete.
17
+ */
18
+ async function ownCoverage(dataDir) {
19
+ try {
20
+ const raw = JSON.parse(await readFile(coveragePath(dataDir), "utf8"));
21
+ return Array.isArray(raw.indexed) ? raw.indexed : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
13
27
  const MIN_TOKEN = 3;
14
28
  const MAX_TOKENS_PER_ARCHIVE = 4000;
15
29
  /**
@@ -201,10 +215,21 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
201
215
  // Drop entries for archives that no longer exist, then index only what is
202
216
  // NEW — the difference between "re-decrypt 53 archives" and "decrypt the
203
217
  // two sealed since the last build".
218
+ //
219
+ // "No longer exists" is a judgement only the machine that sealed it can
220
+ // make. The index is shared by every machine of the vault, so a laptop that
221
+ // has never sealed anything must not conclude that the desktop's fifty
222
+ // archives are gone: it used to delete every entry it had no local record
223
+ // for and push the emptied index back, which wiped the phonebook for the
224
+ // whole vault and left the machine that built it unable to search its own
225
+ // history. A machine may forget only what it itself indexed.
204
226
  const liveIds = new Set(records.map((record) => record.id));
227
+ const mine = new Set(await ownCoverage(dataDir));
205
228
  for (const id of Object.keys(index.archives)) {
206
229
  if (liveIds.has(id))
207
230
  continue;
231
+ if (!mine.has(id))
232
+ continue; // another machine's archive
208
233
  delete index.archives[id];
209
234
  for (const token of Object.keys(index.tokens)) {
210
235
  index.tokens[token] = index.tokens[token].filter((entry) => entry !== id && !entry.startsWith(`${id}@`));
@@ -515,6 +540,34 @@ async function remoteOnlyRecord(dataDir, id, phrase) {
515
540
  const { pullCiphertext, unframeObject } = await import("./cloud.js");
516
541
  const { openIdentity } = await import("./chunk-store.js");
517
542
  const { unwrapArchiveKey } = await import("../packages/vaultline-crypto/src/recipients.js");
543
+ // An ordinary archive pushed to managed storage is ONE object with its
544
+ // envelope in the frame — no identity sidecar, because nothing was
545
+ // chunked. That shape used to fall through to null here, so a second
546
+ // machine could find a streamed archive and not an uploaded one, which
547
+ // is not a distinction anybody asked for. Try it before the chunk layout.
548
+ const single = await pullCiphertext(dataDir, id).catch(() => null);
549
+ if (single) {
550
+ const { envelope: only } = unframeObject(single.ciphertext);
551
+ if (only?.manifest) {
552
+ const bytes = only.manifest.originalBytes ?? only.manifest.plaintextBytes ?? 0;
553
+ return {
554
+ version: 2, id, createdAt: only.manifest.createdAt ?? new Date(0).toISOString(),
555
+ source: {
556
+ path: only.manifest.sourcePath ?? only.manifest.adapter?.sourcePath ?? `archive ${id.slice(0, 8)}`,
557
+ agent: only.manifest.adapter?.agent ?? "unknown",
558
+ bytes,
559
+ sha256: only.manifest.originalSha256 ?? only.manifest.plaintextSha256
560
+ },
561
+ cipher: { algorithm: only.suite, ciphertextSha256: "", storedBytes: single.ciphertext.length, chunks: only.chunks?.length ?? 1 },
562
+ envelope: only,
563
+ objectPath: join(dataDir, "never-materialised", `${id}.vlarchive`),
564
+ remote: {
565
+ provider: "vaultline", bucket: "vaultline-managed", objectKey: id,
566
+ bytes: single.ciphertext.length, checksum: "", verifiedAt: only.manifest.createdAt ?? new Date(0).toISOString()
567
+ }
568
+ };
569
+ }
570
+ }
518
571
  const { ciphertext: sidecar } = await pullCiphertext(dataDir, `${id}.envelope.vlmeta`);
519
572
  const { envelope, ciphertext: identityBlob } = unframeObject(sidecar);
520
573
  if (!envelope)
@@ -29,7 +29,7 @@ export declare class FileSecretBackend implements SecretBackend {
29
29
  export declare function secretBackends(dataDir: string): SecretBackend[];
30
30
  /**
31
31
  * Picks the strongest backend this machine supports. The file fallback always
32
- * qualifies. `VAULTLINE_SECRET_BACKEND` pins the choice, which is how a test suite
32
+ * qualifies. `SEALKEEP_SECRET_BACKEND` pins the choice, which is how a test suite
33
33
  * or a sandboxed environment stays out of the real OS keystore.
34
34
  */
35
35
  export declare function chooseBackend(dataDir: string, preferred?: BackendName, env?: NodeJS.ProcessEnv): Promise<SecretBackend>;
@@ -4,6 +4,7 @@ import { platform } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { fail } from "./errors.js";
7
+ import { envVar } from "./env.js";
7
8
  const run = promisify(execFile);
8
9
  async function commandExists(command) {
9
10
  try {
@@ -130,11 +131,11 @@ export function secretBackends(dataDir) {
130
131
  }
131
132
  /**
132
133
  * Picks the strongest backend this machine supports. The file fallback always
133
- * qualifies. `VAULTLINE_SECRET_BACKEND` pins the choice, which is how a test suite
134
+ * qualifies. `SEALKEEP_SECRET_BACKEND` pins the choice, which is how a test suite
134
135
  * or a sandboxed environment stays out of the real OS keystore.
135
136
  */
136
137
  export async function chooseBackend(dataDir, preferred, env = process.env) {
137
- preferred = preferred ?? env.VAULTLINE_SECRET_BACKEND;
138
+ preferred = preferred ?? envVar("SECRET_BACKEND", env);
138
139
  const backends = secretBackends(dataDir);
139
140
  if (preferred) {
140
141
  const chosen = backends.find((backend) => backend.name === preferred);
@@ -190,7 +191,7 @@ export async function forgetRecoveryPhrase(dataDir, vaultId, preferred) {
190
191
  * everything, without the keystore's opt-in or its stated trade-off.
191
192
  */
192
193
  export async function resolveRecoveryPhrase(dataDir, vaultId, explicit, env = process.env, preferred) {
193
- return explicit ?? env.VAULTLINE_RECOVERY_PHRASE ?? (await recallRecoveryPhrase(dataDir, vaultId, preferred).catch(() => null));
194
+ return explicit ?? envVar("RECOVERY_PHRASE", env) ?? (await recallRecoveryPhrase(dataDir, vaultId, preferred).catch(() => null));
194
195
  }
195
196
  /**
196
197
  * Provider credentials are addressed by storage config id, never by bucket name,
@@ -11,7 +11,7 @@ export type ServiceOptions = {
11
11
  *
12
12
  * A service manager starts the daemon with a bare environment — it does not
13
13
  * inherit the shell that installed it. The upload pass is gated on
14
- * `VAULTLINE_ENABLE_SIGNER`, so a service definition that carries no
14
+ * `SEALKEEP_ENABLE_SIGNER`, so a service definition that carries no
15
15
  * environment produces a daemon that seals sessions, uploads nothing, and
16
16
  * therefore reclaims nothing, logging one line to `daemon.log` that nobody
17
17
  * reads. Whatever the installer decides uploads need, it has to be written
@@ -58,7 +58,7 @@ export declare function serviceStatus(options: ServiceOptions): Promise<{
58
58
  * The environment a service unit needs beyond its flags. A service manager
59
59
  * starts the daemon bare, so anything the installing session was told about
60
60
  * where secrets live has to ride along in the unit — a vault set up with
61
- * VAULTLINE_SECRET_BACKEND=file kept its phrase in a keystore the daemon then
61
+ * SEALKEEP_SECRET_BACKEND=file kept its phrase in a keystore the daemon then
62
62
  * never looked in, and every tick died on "No recovery phrase available".
63
63
  */
64
64
  export declare function serviceUnitEnvironment(base?: Record<string, string>, env?: NodeJS.ProcessEnv): Record<string, string>;
@@ -5,6 +5,7 @@ import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { fail } from "./errors.js";
8
+ import { envVar } from "./env.js";
8
9
  const run = promisify(execFile);
9
10
  export const SERVICE_LABEL = "ai.vaultline.agent";
10
11
  function kindFor(target) {
@@ -165,11 +166,12 @@ export async function serviceStatus(options) {
165
166
  * The environment a service unit needs beyond its flags. A service manager
166
167
  * starts the daemon bare, so anything the installing session was told about
167
168
  * where secrets live has to ride along in the unit — a vault set up with
168
- * VAULTLINE_SECRET_BACKEND=file kept its phrase in a keystore the daemon then
169
+ * SEALKEEP_SECRET_BACKEND=file kept its phrase in a keystore the daemon then
169
170
  * never looked in, and every tick died on "No recovery phrase available".
170
171
  */
171
172
  export function serviceUnitEnvironment(base = {}, env = process.env) {
172
- return { ...(env.VAULTLINE_SECRET_BACKEND ? { VAULTLINE_SECRET_BACKEND: env.VAULTLINE_SECRET_BACKEND } : {}), ...base };
173
+ const backend = envVar("SECRET_BACKEND", env);
174
+ return { ...(backend ? { SEALKEEP_SECRET_BACKEND: backend } : {}), ...base };
173
175
  }
174
176
  /** The argv the service should run: this package's CLI, with the daemon subcommand. */
175
177
  export function daemonInvocation(dataDir, options = {}) {
@@ -64,7 +64,7 @@ function s3Plan(input) {
64
64
  finish: [
65
65
  `sealkeep storage configure --provider s3 --bucket ${bucket} --prefix ${prefix} --region ${region}`,
66
66
  `echo '{"accessKeyId":"AKIA…","secretAccessKey":"…"}' | sealkeep storage credentials set`,
67
- `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all`
67
+ `SEALKEEP_ENABLE_SIGNER=1 sealkeep upload --all`
68
68
  ],
69
69
  notes: [
70
70
  "Enable versioning on the bucket if you want protection against an accidental overwrite.",
@@ -100,7 +100,7 @@ function r2Plan(input) {
100
100
  finish: [
101
101
  `sealkeep storage configure --provider r2 --bucket ${bucket} --prefix ${prefix} --region auto`,
102
102
  `echo '{"accessKeyId":"<R2 access key id>","secretAccessKey":"<R2 secret>"}' | sealkeep storage credentials set`,
103
- `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all --endpoint https://${accountId}.r2.cloudflarestorage.com`
103
+ `SEALKEEP_ENABLE_SIGNER=1 sealkeep upload --all --endpoint https://${accountId}.r2.cloudflarestorage.com`
104
104
  ],
105
105
  notes: [
106
106
  "R2 tokens are scoped per bucket in the dashboard, so the prefix restriction is Sealkeep's own object-key discipline rather than an IAM condition.",
@@ -151,7 +151,7 @@ function gcsPlan(input) {
151
151
  `sealkeep storage configure --provider gcs --bucket ${bucket} --prefix ${prefix}`,
152
152
  `node -e 'const k=require("./vaultline-key.json");process.stdout.write(JSON.stringify({clientEmail:k.client_email,privateKey:k.private_key}))' \\\n | sealkeep storage credentials set`,
153
153
  `rm vaultline-key.json # the key now lives in your keychain`,
154
- `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all`
154
+ `SEALKEEP_ENABLE_SIGNER=1 sealkeep upload --all`
155
155
  ],
156
156
  notes: [
157
157
  "Delete the downloaded key file once it is in your keychain. A key on disk is the usual way these leak.",
@@ -188,7 +188,7 @@ function gdrivePlan(input) {
188
188
  title: "Connect your Google account",
189
189
  why: `Sealkeep asks for drive.file only: it can see files it created — a "${folder}" folder of ciphertext — and nothing else in the Drive.`,
190
190
  commands: [
191
- `export VAULTLINE_GDRIVE_CLIENT_ID="<your-client-id>.apps.googleusercontent.com"`,
191
+ `export SEALKEEP_GDRIVE_CLIENT_ID="<your-client-id>.apps.googleusercontent.com"`,
192
192
  `sealkeep storage connect gdrive`
193
193
  ]
194
194
  }
@@ -196,7 +196,7 @@ function gdrivePlan(input) {
196
196
  finish: [
197
197
  `# once the provider registry accepts gdrive targets:`,
198
198
  `sealkeep storage configure --provider gdrive --bucket ${folder} --prefix ${prefix}`,
199
- `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all`
199
+ `SEALKEEP_ENABLE_SIGNER=1 sealkeep upload --all`
200
200
  ],
201
201
  notes: [
202
202
  `Archives land in a "${folder}" folder as sealed ciphertext; a prefix of "appdata" uses Drive's hidden per-app area instead.`,
@@ -19,6 +19,9 @@ export type StorageTarget = StorageTargetConfig;
19
19
  */
20
20
  export declare function resolveTargets(dataDir: string): Promise<StorageTarget[]>;
21
21
  /** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
22
+ /** Re-pushes the current rules with fresh usage stamps — machines are the only
23
+ * ones who can see Drive and BYO bytes, so the account learns them from here. */
24
+ export declare function pushTargetUsage(dataDir: string): Promise<void>;
22
25
  export declare function pushTargetsToCloud(dataDir: string, targets: StorageTarget[]): Promise<boolean>;
23
26
  /** Bytes each target currently holds, from the records: targetId when stamped, provider+bucket for older records. */
24
27
  export declare function targetUsage(dataDir: string, targets: StorageTarget[]): Promise<Map<string, number>>;
@@ -1,6 +1,7 @@
1
1
  import { fail } from "./errors.js";
2
2
  import { readConfig, listArchives } from "./vault.js";
3
3
  import { isV2 } from "./types.js";
4
+ import { envVar } from "./env.js";
4
5
  const defaultPriority = (target) => target.priority ?? (target.provider === "vaultline" ? 0 : 10);
5
6
  /**
6
7
  * The effective target list. Explicit `storageTargets` in config wins;
@@ -43,7 +44,7 @@ async function pullTargetsFromCloud(dataDir) {
43
44
  if (!token)
44
45
  return null;
45
46
  const { DEFAULT_CLOUD_URL } = await import("./cloud.js");
46
- const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
47
+ const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
47
48
  const response = await fetch(`${base}/v1/cloud/storage-targets`, { headers: { authorization: `Bearer ${token}` } });
48
49
  if (!response.ok)
49
50
  return null;
@@ -59,11 +60,30 @@ async function pullTargetsFromCloud(dataDir) {
59
60
  }
60
61
  }
61
62
  /** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
63
+ /** Re-pushes the current rules with fresh usage stamps — machines are the only
64
+ * ones who can see Drive and BYO bytes, so the account learns them from here. */
65
+ export async function pushTargetUsage(dataDir) {
66
+ try {
67
+ const targets = await resolveTargets(dataDir);
68
+ if (!targets.length)
69
+ return;
70
+ await pushTargetsToCloud(dataDir, targets);
71
+ }
72
+ catch { /* advisory */ }
73
+ }
62
74
  export async function pushTargetsToCloud(dataDir, targets) {
63
75
  try {
76
+ const usage = await targetUsage(dataDir, targets).catch(() => null);
77
+ if (usage) {
78
+ const now = new Date().toISOString();
79
+ targets = targets.map((t) => {
80
+ const used = usage.get(t.id);
81
+ return used && used > 0 ? { ...t, usedBytes: used, usedAt: now } : t;
82
+ });
83
+ }
64
84
  const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
65
85
  const token = await cloudToken(dataDir);
66
- const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
86
+ const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
67
87
  const response = await fetch(`${base}/v1/cloud/storage-targets`, {
68
88
  method: "PUT",
69
89
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -133,6 +133,9 @@ export type StorageTargetConfig = {
133
133
  maxGb?: number;
134
134
  projects?: string[];
135
135
  priority?: number;
136
+ /** Stamped by machines when they push: bytes this target holds, per their records. */
137
+ usedBytes?: number;
138
+ usedAt?: string;
136
139
  };
137
140
  export declare function isV2(record: ArchiveRecord): record is ArchiveRecordV2;
138
141
  export type VaultConfig = {
@@ -59,6 +59,10 @@ export async function uploadArchive(dataDir, archiveId, options = {}) {
59
59
  const next = { ...record, remote };
60
60
  await writeRecord(config.storage.root, next);
61
61
  await recordAudit(dataDir, "upload.verify", "allowed", { archiveId, objectKey: lease.objectKey, bytes: ciphertext.length, provider: lease.provider });
62
+ {
63
+ const { pushTargetUsage } = await import("./storage-targets.js");
64
+ void pushTargetUsage(dataDir);
65
+ }
62
66
  return { archiveId, objectKey: lease.objectKey, bytes: ciphertext.length, checksum: localChecksum, verified: true, provider: lease.provider };
63
67
  }
64
68
  /** Uploads every archive that has no verified remote object yet. */
package/dist/src/vault.js CHANGED
@@ -10,6 +10,7 @@ import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRec
10
10
  import { fail, VaultlineError } from "./errors.js";
11
11
  import { recordAudit } from "./audit.js";
12
12
  import { isV2 } from "./types.js";
13
+ import { envVar } from "./env.js";
13
14
  /** Bumped when the adapter's preservation behaviour changes, recorded in every envelope. */
14
15
  /**
15
16
  * Writes one archive record atomically.
@@ -130,14 +131,14 @@ export async function initialize(dataDir, providedPhrase, opts = {}) {
130
131
  * sessions on a 16 GB machine were simply refused. Archiving now streams a
131
132
  * chunk at a time, so a transcript's size no longer predicts what it costs to
132
133
  * archive and a machine-derived refusal would only turn away sessions this
133
- * build can handle. Only an explicit VAULTLINE_MAX_ARCHIVE_BYTES still refuses.
134
+ * build can handle. Only an explicit SEALKEEP_MAX_ARCHIVE_BYTES still refuses.
134
135
  */
135
136
  function assertArchivable(absolute, size) {
136
- const ceiling = Number(process.env.VAULTLINE_MAX_ARCHIVE_BYTES ?? "");
137
+ const ceiling = Number(envVar("MAX_ARCHIVE_BYTES") ?? "");
137
138
  if (!Number.isFinite(ceiling) || ceiling <= 0 || size <= ceiling)
138
139
  return;
139
140
  const gb = (value) => `${(value / 1024 ** 3).toFixed(1)} GB`;
140
- fail("source_unreadable", `${absolute} is ${gb(size)}, above the VAULTLINE_MAX_ARCHIVE_BYTES ceiling of ${gb(ceiling)}. Raise or unset VAULTLINE_MAX_ARCHIVE_BYTES to archive it.`, { sourcePath: absolute, bytes: size, ceiling });
141
+ fail("source_unreadable", `${absolute} is ${gb(size)}, above the SEALKEEP_MAX_ARCHIVE_BYTES ceiling of ${gb(ceiling)}. Raise or unset SEALKEEP_MAX_ARCHIVE_BYTES to archive it.`, { sourcePath: absolute, bytes: size, ceiling });
141
142
  }
142
143
  /**
143
144
  * The archive whose bytes are still a prefix of this source, and how many.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -39,7 +39,9 @@
39
39
  "control-plane": "tsx src/control-plane-cli.ts",
40
40
  "dashboard": "tsx src/dashboard-cli.ts",
41
41
  "build:site": "node tools/build-site.mjs",
42
- "bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\""
42
+ "bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
43
+ "acceptance": "node scripts/acceptance.mjs",
44
+ "acceptance:managed": "node scripts/acceptance.mjs --managed"
43
45
  },
44
46
  "devDependencies": {
45
47
  "@types/node": "^22.10.2",