sealkeep 0.5.3 → 0.6.1

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 CHANGED
@@ -94,7 +94,7 @@ Full specification, test vectors, and tamper coverage: `packages/vaultline-crypt
94
94
 
95
95
  ## 4. Control-plane API v1
96
96
 
97
- Authentication is on by default. `VAULTLINE_CONTROL_PLANE_DEV=1` disables device signatures
97
+ Authentication is on by default. `SEALKEEP_CONTROL_PLANE_DEV=1` disables device signatures
98
98
  and keeps state in memory for local interface work; `/health` reports
99
99
  `mode: development-interface` so a misconfigured deployment is visible in one request.
100
100
 
@@ -198,4 +198,4 @@ Four gates remain open, none closable by writing more code in this repository:
198
198
  3. **OS code-signing certificates.** Releases carry npm provenance and an Ed25519-signed manifest only.
199
199
  4. **Browser and accessibility QA** of the dashboard.
200
200
 
201
- Uploads remain off unless `VAULTLINE_ENABLE_SIGNER=1` is set explicitly.
201
+ Uploads remain off unless `SEALKEEP_ENABLE_SIGNER=1` is set explicitly.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  Notable changes, by published version. Sealkeep is pre-1.0: minor versions
4
4
  may change behavior, and say so here when they do.
5
5
 
6
+ ## 0.6.1 — 2026-08-19
7
+
8
+ - Reading an offloaded archive now checks for free space first. An index build
9
+ reads each archive in full, and on a machine already short of space that
10
+ filled the disk and died mid-build — the failure this product exists to
11
+ prevent. It refuses in words that name the archive and the shortfall, keeps a
12
+ 512 MB reserve, and lets the build carry on with the rest.
13
+
14
+ ## 0.6.0 — 2026-08-19
15
+
16
+ **A second machine no longer wipes your search index.** The content index is
17
+ shared by every machine of a vault, but each machine pruned it from its own
18
+ records: a laptop that had never sealed anything concluded every entry was
19
+ dead, deleted them, and pushed the emptied index back — after which the machine
20
+ that built it could not search its own history either. A machine now forgets
21
+ only what it itself indexed. If this bit you, `sealkeep index build` on the
22
+ machine holding the archives restores the phonebook for the whole vault.
23
+
24
+ - Cross-machine search now finds ordinary uploaded archives, not just streamed
25
+ ones: a hit with no local record is rebuilt from the archive's own frame.
26
+ - Environment variables are `SEALKEEP_*`. The Vaultline-era names still work,
27
+ so nothing on an existing machine breaks; the new name wins when both are set.
28
+ - The CLI honours `SEALKEEP_DATA_DIR` — the variable `mcp install` already
29
+ writes into agent configs — so the agent and the CLI cannot silently work on
30
+ different vaults.
31
+ - New: `npm run acceptance` drives 22 end-to-end scenarios through the shipped
32
+ CLI (see `docs/acceptance-matrix.md`).
33
+
6
34
  ## 0.5.3 — 2026-08-19
7
35
 
8
36
  The account now knows what every destination holds. Machines stamp
package/CONTROL_PLANE.md CHANGED
@@ -6,7 +6,7 @@ The control plane stores device identities, encrypted manifests, and provider co
6
6
 
7
7
  ```sh
8
8
  npm run control-plane # authenticated, durable
9
- VAULTLINE_CONTROL_PLANE_DEV=1 npm run control-plane # unauthenticated, in memory
9
+ SEALKEEP_CONTROL_PLANE_DEV=1 npm run control-plane # unauthenticated, in memory
10
10
  docker build -t vaultline-control-plane . && docker run -p 8787:8787 -v vaultline-data:/data vaultline-control-plane
11
11
  ```
12
12
 
@@ -35,7 +35,7 @@ presented in `x-vaultline-device`, `x-vaultline-timestamp`, `x-vaultline-nonce`,
35
35
  refused, and a nonce cache rejects verbatim replays. Binding method, path, and body hash
36
36
  into the signature means a captured signature cannot be redirected to another endpoint.
37
37
 
38
- `VAULTLINE_CONTROL_PLANE_DEV=1` disables this and keeps state in memory. It exists for
38
+ `SEALKEEP_CONTROL_PLANE_DEV=1` disables this and keeps state in memory. It exists for
39
39
  local interface development. If `/health` reports `development-interface` on anything you
40
40
  deployed, stop it.
41
41
 
@@ -61,7 +61,7 @@ Input is ciphertext-only metadata: archive ID, encrypted byte length, SHA-256 of
61
61
 
62
62
  Every lease carries `status` and `uploadable`. In this build they are always `pending-signer` and `false`. `assertLeaseUsable` is the single gate an upload path must pass: it checks expiry first, so a replayed lease is refused as `lease_expired` rather than as an unsigned one, and it then refuses anything that is not an `active` uploadable lease with `signer_not_configured`.
63
63
 
64
- `VAULTLINE_ENABLE_SIGNER=1` permits registering a `ProviderUploadClient`, but no implementation ships. With the flag off, registration and lookup both fail; turning it off again immediately withdraws every registered client.
64
+ `SEALKEEP_ENABLE_SIGNER=1` permits registering a `ProviderUploadClient`, but no implementation ships. With the flag off, registration and lookup both fail; turning it off again immediately withdraws every registered client.
65
65
 
66
66
  For S3/R2 the response contains `createMultipart`, per-part presigned PUT URLs, and a completion endpoint. For GCS it contains a signed initiation URL plus the resumable session URI returned to the local agent. The client validates the remote checksum and then calls `POST /v1/archives/complete`.
67
67
 
package/README.md CHANGED
@@ -30,7 +30,7 @@ heartbeat every cycle, so `status`, the dashboard, and the local API all answer
30
30
  "is it running, when did it last check, and what has it sealed" from the same fact.
31
31
  It also raises a native notification when disk is reclaimed or something needs a
32
32
  decision — never for routine sealing, which would just train you to ignore it.
33
- Silence it entirely with `VAULTLINE_NOTIFICATIONS=off`.
33
+ Silence it entirely with `SEALKEEP_NOTIFICATIONS=off`.
34
34
 
35
35
  Prefer to drive it yourself? `sealkeep quickstart` does the same setup without installing
36
36
  a service, and you run `sealkeep queue run` when you feel like it.
@@ -70,7 +70,7 @@ Autopilot runs all four on a loop. To unlock the vault without a human present,
70
70
  your phrase in the OS keystore — macOS Keychain, Freedesktop Secret Service, or Windows
71
71
  DPAPI. That is a real trade-off, stated plainly: anything running as you can read it once
72
72
  you are logged in. Opt out with `--no-remember` and supply
73
- `VAULTLINE_RECOVERY_PHRASE` to the service yourself. Either way, the phrase you wrote down
73
+ `SEALKEEP_RECOVERY_PHRASE` to the service yourself. Either way, the phrase you wrote down
74
74
  stays your disaster-recovery path.
75
75
 
76
76
  ## Your recovery phrase is 24 words
@@ -191,7 +191,7 @@ and phrase still never leave your machine.
191
191
  ```sh
192
192
  sealkeep storage configure --provider r2 --bucket encrypted-sessions --prefix accounts/me
193
193
  echo '{"accessKeyId":"…","secretAccessKey":"…"}' | sealkeep storage credentials set
194
- VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all
194
+ SEALKEEP_ENABLE_SIGNER=1 sealkeep upload --all
195
195
  ```
196
196
 
197
197
  Or skip storage entirely and keep everything local. Sealkeep still seals every archive; it
@@ -908,7 +908,18 @@ sealkeep open 08424eb6… ./session.jsonl \
908
908
  /* Every error a catch renders into the page goes through this: the first 200
909
909
  characters are a message, anything past that is a payload nobody meant to
910
910
  put on screen. */
911
- const errText = (error) => String((error && error.message) || error || "Something went wrong").slice(0, 200);
911
+ /* The control plane names its own internals when a step refuses — "Error at
912
+ step Precondition: that confirmation link is no longer valid". The half
913
+ after the colon is written for a person; the half before it is our
914
+ plumbing, and seeing it makes a clear refusal look like a crash. Strip the
915
+ prefix, keep the sentence, and capitalise what is left. */
916
+ const errText = (error) => {
917
+ let text = String((error && error.message) || error || "Something went wrong");
918
+ text = text.replace(/^\s*Error at step\s+[^:]{0,60}:\s*/i, "");
919
+ text = text.replace(/^\s*(Error|Exception|Failure)\s*:\s*/i, "");
920
+ if (text) text = text.charAt(0).toUpperCase() + text.slice(1);
921
+ return text.slice(0, 200);
922
+ };
912
923
  const bytes = (n) => {
913
924
  n = Number(n) || 0;
914
925
  for (const [u, d] of [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]])
@@ -927,7 +938,15 @@ sealkeep open 08424eb6… ./session.jsonl \
927
938
  const headers = {};
928
939
  if (body) headers["content-type"] = "application/json";
929
940
  if (token) headers.authorization = "Bearer " + token;
930
- const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
941
+ // A dropped connection surfaces as TypeError("Failed to fetch") — the
942
+ // browser's words, not ours, and they tell a customer nothing about what
943
+ // to do. Everything else in this panel refuses in a sentence; so does this.
944
+ let res;
945
+ try {
946
+ res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
947
+ } catch {
948
+ throw new Error("No answer from the service. Check your connection and try again.");
949
+ }
931
950
  // A gateway answering for a dead service sends an HTML error page, not
932
951
  // JSON. That markup used to ride error.message straight into spans like
933
952
  // pair-msg — raw <html> on screen. When the body does not parse, the
@@ -961,6 +980,40 @@ sealkeep open 08424eb6… ./session.jsonl \
961
980
  if (which === "account" && !wasPanel && !document.body.classList.contains("booting")) enterPanel();
962
981
  }
963
982
 
983
+ /* In-panel navigation. The pills and the plan chip both land here rather
984
+ than leaving it to the native anchor jump: a programmatic smooth scroll is
985
+ dropped on the floor in some browsers — observed live, where the hash
986
+ changed and the page never moved — so this checks whether anything
987
+ actually happened and takes the plain jump if not. A nav that does nothing
988
+ is worse than one that does not animate. The offset clears the sticky nav. */
989
+ function goTo(el) {
990
+ if (!el) return;
991
+ const start = window.scrollY;
992
+ // The nav wraps to two rows on a phone, so a fixed offset would park the
993
+ // card underneath it. Measure what is actually sticking.
994
+ const nav = document.querySelector(".acct-nav");
995
+ const clearance = (nav ? nav.getBoundingClientRect().height : 0) + 12;
996
+ const target = Math.max(0, el.getBoundingClientRect().top + start - clearance);
997
+ const smooth = motionOK();
998
+ window.scrollTo({ top: target, behavior: smooth ? "smooth" : "instant" });
999
+ if (!smooth) return;
1000
+ setTimeout(() => {
1001
+ if (Math.abs(window.scrollY - start) < 4 && Math.abs(target - start) > 24) {
1002
+ window.scrollTo({ top: target, behavior: "instant" });
1003
+ }
1004
+ }, 320);
1005
+ }
1006
+ for (const link of document.querySelectorAll(".acct-nav a")) {
1007
+ link.addEventListener("click", (event) => {
1008
+ const id = link.getAttribute("href").slice(1);
1009
+ const target = document.getElementById(id);
1010
+ if (!target) return;
1011
+ event.preventDefault();
1012
+ history.replaceState(null, "", location.pathname + location.search + "#" + id);
1013
+ goTo(target);
1014
+ });
1015
+ }
1016
+
964
1017
  /* "Account" is a promise the page has to keep on the first click, not the
965
1018
  second. Routing it here rather than leaving it to the native anchor means
966
1019
  exactly one thing is scrolling, and it runs after the state that decides
@@ -994,7 +1047,7 @@ sealkeep open 08424eb6… ./session.jsonl \
994
1047
  $("a-name").value = user.label || "";
995
1048
  $("a-plan").textContent = paid ? "Managed storage" : "Free";
996
1049
  $("a-plan").className = "chip" + (paid ? " pro" : "");
997
- $("a-plan").onclick = () => $("c-plan").scrollIntoView({ behavior: "smooth", block: "start" });
1050
+ $("a-plan").onclick = () => goTo($("c-plan"));
998
1051
  $("a-who").textContent = [
999
1052
  user.email,
1000
1053
  "joined " + day(user.created_at),
@@ -1198,13 +1251,16 @@ sealkeep open 08424eb6… ./session.jsonl \
1198
1251
  // projects, priority}]. Editing here lands on every machine of the vault,
1199
1252
  // because resolveTargets is cloud-first.
1200
1253
  let targetRows = [];
1254
+ let planIsPaid = false;
1201
1255
  const providerLabel = (p) => ({ vaultline: "Sealkeep cloud", gdrive: "Google Drive", r2: "Cloudflare R2", s3: "S3", gcs: "Google Cloud" }[p] || p);
1202
1256
  function renderTargets() {
1203
1257
  const body = $("tg-body");
1204
1258
  body.replaceChildren();
1205
1259
  if (!targetRows.length) {
1206
1260
  $("tg-msg").className = "empty";
1207
- $("tg-msg").textContent = "No rules yet — everything goes to Sealkeep cloud. Add your Drive or set rules from a machine: sealkeep storage targets add …";
1261
+ $("tg-msg").textContent = planIsPaid
1262
+ ? "No rules yet — everything goes to Sealkeep cloud. Add a destination here, or set rules from a machine: sealkeep storage targets add …"
1263
+ : "No rules yet — each machine seals to the bucket configured on it. Add a destination here to route by project or cap it.";
1208
1264
  $("tg-wrap").style.display = "none";
1209
1265
  return;
1210
1266
  }
@@ -1315,6 +1371,7 @@ sealkeep open 08424eb6… ./session.jsonl \
1315
1371
  // an empty state is prose, so it is set in prose. .msg is mono because
1316
1372
  // it reports what a machine did.
1317
1373
  on("s-intro", false);
1374
+ $("a-count").textContent = "0";
1318
1375
  $("s-msg").className = "empty";
1319
1376
  $("s-msg").textContent = "Nothing stored yet. Archives appear here on their own once a machine is connected.";
1320
1377
  $("s-wrap").style.display = "none";
@@ -1388,6 +1445,9 @@ sealkeep open 08424eb6… ./session.jsonl \
1388
1445
  try { user = await api("/me", { token }); }
1389
1446
  catch { sessionStorage.removeItem(TOKEN); return false; }
1390
1447
 
1448
+ // Set before any card renders: the routing card's empty state asks whether
1449
+ // this account stores with us, and it draws during loadTargets below.
1450
+ planIsPaid = user.plan === "pro";
1391
1451
  renderAccount(user);
1392
1452
  // Each section fails on its own. One dead panel must not blank the rest.
1393
1453
  if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
@@ -1401,18 +1461,28 @@ sealkeep open 08424eb6… ./session.jsonl \
1401
1461
  // The glance line: what is connected, where seals go — from data already fetched.
1402
1462
  $("a-machines").textContent = String(devices);
1403
1463
  $("a-dest").textContent = String(Math.max(targetRows.length, 1));
1404
- const destNames = (targetRows.length ? targetRows : [{ provider: "vaultline" }])
1405
- .slice().sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10))
1464
+ const destNames = targetRows.slice()
1465
+ .sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10))
1406
1466
  .map((t) => providerLabel(t.provider) + (t.maxGb ? " ≤" + t.maxGb + " GB" : ""));
1407
1467
  const driveUsed = targetRows.filter((t) => t.provider === "gdrive" && t.usedBytes)
1408
1468
  .reduce((n, t) => n + t.usedBytes, 0);
1409
1469
  const byoUsed = targetRows.filter((t) => t.provider !== "gdrive" && t.provider !== "vaultline" && t.usedBytes)
1410
1470
  .reduce((n, t) => n + t.usedBytes, 0);
1411
- $("a-hero").textContent = bytes(Number(user.used_bytes) || 0) + " in Sealkeep cloud"
1412
- + (driveUsed ? " · " + bytes(driveUsed) + " in Google Drive" : "")
1413
- + (byoUsed ? " · " + bytes(byoUsed) + " in your bucket" : "");
1471
+ // Only a paid account stores with us. Saying "0 B in Sealkeep cloud" to
1472
+ // someone who runs their own bucket states a number about a place their
1473
+ // archives never go — the free line names their storage instead.
1474
+ const paidPlan = planIsPaid;
1475
+ const parts = [];
1476
+ if (paidPlan) parts.push(bytes(Number(user.used_bytes) || 0) + " in Sealkeep cloud");
1477
+ if (driveUsed) parts.push(bytes(driveUsed) + " in Google Drive");
1478
+ if (byoUsed) parts.push(bytes(byoUsed) + " in your bucket");
1479
+ $("a-hero").textContent = parts.length ? parts.join(" · ")
1480
+ : "Your archives live in storage you run";
1414
1481
  $("a-summary").textContent =
1415
- devices + " machine" + (devices === 1 ? "" : "s") + " connected · new archives go to " + destNames.join(", then ");
1482
+ (devices ? devices + " machine" + (devices === 1 ? "" : "s") + " connected" : "No machine connected yet")
1483
+ + " · new archives go to "
1484
+ + (destNames.length ? destNames.join(", then ")
1485
+ : paidPlan ? "Sealkeep cloud" : "the bucket each machine is configured with");
1416
1486
  const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1417
1487
  on("a-firstrun", nothingYet);
1418
1488
  $(nothingYet ? "pair-home-first" : "pair-home-more").append($("pair"));
package/dist/site.zip CHANGED
Binary file
@@ -286,11 +286,11 @@ export function hookConfig(agent, executable = "vaultline", dataDir = "~/.vaultl
286
286
  PostCompact: entry(3),
287
287
  SessionStart: rehydrateEntry
288
288
  },
289
- _vaultlineNote: "Run `sealkeep queue run` (with VAULTLINE_RECOVERY_PHRASE set) to encrypt queued sessions. If this Codex version has no SessionStart hook, the entry is ignored; `sealkeep recover <session-file>` is the manual door."
289
+ _vaultlineNote: "Run `sealkeep queue run` (with SEALKEEP_RECOVERY_PHRASE set) to encrypt queued sessions. If this Codex version has no SessionStart hook, the entry is ignored; `sealkeep recover <session-file>` is the manual door."
290
290
  };
291
291
  return {
292
292
  hooks: { SessionEnd: entry(5), SessionStart: rehydrateEntry },
293
- _vaultlineNote: "Merge the hooks object into ~/.claude/settings.json. The hook stores no secret; run `sealkeep queue run` with VAULTLINE_RECOVERY_PHRASE to encrypt queued sessions. SessionStart(resume) puts archived transcripts back before the resume reads them."
293
+ _vaultlineNote: "Merge the hooks object into ~/.claude/settings.json. The hook stores no secret; run `sealkeep queue run` with SEALKEEP_RECOVERY_PHRASE to encrypt queued sessions. SessionStart(resume) puts archived transcripts back before the resume reads them."
294
294
  };
295
295
  }
296
296
  /**
@@ -34,11 +34,11 @@ export async function enableAutopilot(dataDir, options = {}) {
34
34
  remembered = await rememberRecoveryPhrase(dataDir, config.vaultId, phrase, options.backend);
35
35
  }
36
36
  catch (error) {
37
- notes.push(`Could not use this machine's keystore (${error instanceof Error ? error.message.split("\n")[0] : "unknown"}). The service will need VAULTLINE_RECOVERY_PHRASE in its environment.`);
37
+ notes.push(`Could not use this machine's keystore (${error instanceof Error ? error.message.split("\n")[0] : "unknown"}). The service will need SEALKEEP_RECOVERY_PHRASE in its environment.`);
38
38
  }
39
39
  }
40
40
  else if (!remember) {
41
- notes.push("The phrase was not stored. Put VAULTLINE_RECOVERY_PHRASE in the service environment yourself, or sessions will queue up unsealed.");
41
+ notes.push("The phrase was not stored. Put SEALKEEP_RECOVERY_PHRASE in the service environment yourself, or sessions will queue up unsealed.");
42
42
  }
43
43
  const desired = { policy: reclaim ? "archive-and-reclaim" : "sync-only", olderThanDays: options.olderThanDays ?? DEFAULTS.olderThanDays, graceDays: options.graceDays ?? DEFAULTS.graceDays };
44
44
  // A vault written before archive lifecycle existed has no deleteAfterDays; absent means never.
@@ -89,7 +89,7 @@ export async function enableAutopilot(dataDir, options = {}) {
89
89
  // this the daemon takes the "uploads are disabled" branch on every tick,
90
90
  // and since reclaiming requires a verified remote copy, it also never frees
91
91
  // a byte — the one thing the user installed it to do.
92
- environment: serviceUnitEnvironment(hasRemoteTarget ? { VAULTLINE_ENABLE_SIGNER: "1" } : {})
92
+ environment: serviceUnitEnvironment(hasRemoteTarget ? { SEALKEEP_ENABLE_SIGNER: "1" } : {})
93
93
  };
94
94
  const service = options.dryRun
95
95
  ? { kind: servicePlan(serviceOptions).kind, installed: false, path: servicePlan(serviceOptions).path, ranCommands: [], note: "dry run: nothing was installed" }
package/dist/src/cli.js CHANGED
@@ -36,6 +36,7 @@ import { setupPlan } from "./storage-setup.js";
36
36
  import { connectGdrive } from "./providers/gdrive.js";
37
37
  import { resolveRecoveryPhrase } from "./secrets.js";
38
38
  import { amber, bold, blue, bytes, callout, command as cmd, dim, green, heading, hint, keyValue, mark, relativeTime, shortPath, steps, table, vaultline } from "./ui.js";
39
+ import { envVar } from "./env.js";
39
40
  const VALUE_FLAGS = new Set(["--data-dir", "--recovery-phrase", "--agent", "--home", "--limit", "--executable", "--provider", "--bucket", "--prefix", "--region", "--older-than-days", "--status", "--max", "--port", "--overwrite", "--label", "--public-key", "--config-id", "--backend", "--endpoint", "--policy", "--grace-days", "--interval", "--manifest", "--artifact", "--key", "--current", "--api", "--token", "--group", "--cli-path", "--account-id", "--project", "--out", "--password"]);
40
41
  function take(args, flag, fallback) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : fallback; }
41
42
  function positionals(args) { return args.filter((value, index) => !value.startsWith("-") && !VALUE_FLAGS.has(args[index - 1] ?? "")); }
@@ -47,7 +48,7 @@ function agentId(value) { const agent = required(value, "Agent must be codex or
47
48
  async function unlock(dataDir, explicit) {
48
49
  const config = await readConfig(dataDir).catch(() => null);
49
50
  if (!config)
50
- return explicit ?? process.env.VAULTLINE_RECOVERY_PHRASE;
51
+ return explicit ?? envVar("RECOVERY_PHRASE");
51
52
  const resolved = await resolveRecoveryPhrase(dataDir, config.vaultId, explicit);
52
53
  if (resolved)
53
54
  return resolved;
@@ -250,7 +251,12 @@ function usage() {
250
251
  }
251
252
  async function main() {
252
253
  const [command, ...args] = process.argv.slice(2);
253
- const dataDir = take(args, "--data-dir", defaultDataDir());
254
+ // The product itself writes SEALKEEP_DATA_DIR into the agent configs that
255
+ // `mcp install` generates, and the MCP server honours it — so a person who
256
+ // exports it and then runs the CLI reasonably expects the same vault. It used
257
+ // to be ignored here, which meant the CLI quietly worked on a different vault
258
+ // than the agent did. An explicit --data-dir still wins over the variable.
259
+ const dataDir = take(args, "--data-dir", envVar("DATA_DIR") ?? defaultDataDir());
254
260
  const json = args.includes("--json");
255
261
  // --version is the first thing anyone types at an unfamiliar CLI, and it was
256
262
  // answering "Unknown command". Read it from the manifest rather than hardcoding
@@ -371,7 +377,7 @@ async function main() {
371
377
  ? `automatically ${dim(`· phrase kept in ${result.remembered.backend}`)}`
372
378
  : dryRun && !args.includes("--no-remember")
373
379
  ? `automatically ${dim("· the phrase would be kept in this machine's keystore")}`
374
- : `${mark.warn()} needs VAULTLINE_RECOVERY_PHRASE`],
380
+ : `${mark.warn()} needs SEALKEEP_RECOVERY_PHRASE`],
375
381
  ["Reclaims", result.reclaimEnabled ? `after ${result.retention.olderThanDays}d ${dim(`+ ${result.retention.graceDays}d grace, once a remote copy is verified`)}` : dim("never — sync only")],
376
382
  ["Starts", result.service.installed ? `at login ${dim(`· ${result.service.kind}`)}` : dryRun ? dim(`${result.service.kind} service at ${shortPath(result.service.path, 44)}`) : `${mark.warn()} not installed`]
377
383
  ]));
@@ -424,7 +430,7 @@ async function main() {
424
430
  print(` ${dim("Could not reach your account, so the last known sync rules are in force.")}`);
425
431
  print(heading("Next"));
426
432
  print(steps([
427
- `Encrypt what is queued: ${cmd(`VAULTLINE_RECOVERY_PHRASE="…" sealkeep queue run`)}`,
433
+ `Encrypt what is queued: ${cmd(`SEALKEEP_RECOVERY_PHRASE="…" sealkeep queue run`)}`,
428
434
  `Archive automatically: ${cmd("vaultline agents hook-config codex")} ${dim("(review, then merge)")}`,
429
435
  `See it: ${cmd("sealkeep desktop")}`
430
436
  ]));
@@ -441,7 +447,7 @@ async function main() {
441
447
  print(`\n${BRAND}\n`);
442
448
  const choice = await onboard();
443
449
  const filled = choice.mode === "paid"
444
- ? { ...choice, password: process.env.VAULTLINE_CLOUD_PASSWORD ?? await promptForPhrase("Choose a password (12+ characters): ") }
450
+ ? { ...choice, password: envVar("CLOUD_PASSWORD") ?? await promptForPhrase("Choose a password (12+ characters): ") }
445
451
  : choice;
446
452
  const result = await start(dataDir, filled);
447
453
  print(`\n${recoveryPhraseScreen(result.phrase)}`);
@@ -471,7 +477,7 @@ async function main() {
471
477
  ? await start(dataDir, {
472
478
  mode: "paid",
473
479
  email: take(args, "--email") ?? fail("invalid_argument", "--email is required for a paid account"),
474
- password: process.env.VAULTLINE_CLOUD_PASSWORD ?? await promptForPhrase("Choose a password (12+ characters): "),
480
+ password: envVar("CLOUD_PASSWORD") ?? await promptForPhrase("Choose a password (12+ characters): "),
475
481
  label: take(args, "--label", "this machine")
476
482
  })
477
483
  : await start(dataDir, {
@@ -558,12 +564,12 @@ async function main() {
558
564
  const email = take(args, "--email") ?? fail("invalid_argument", "--email is required");
559
565
  // A one-time code from the panel is the only way in for an account that
560
566
  // signed up with Google, and it works for password accounts too.
561
- const code = take(args, "--code") ?? process.env.VAULTLINE_CLOUD_CODE;
567
+ const code = take(args, "--code") ?? envVar("CLOUD_CODE");
562
568
  const account = code
563
569
  ? await cloud.loginWithCode(dataDir, { email: email, token: code })
564
570
  : await cloud.login(dataDir, {
565
571
  email: email,
566
- password: process.env.VAULTLINE_CLOUD_PASSWORD ?? await promptForPhrase("Password: ")
572
+ password: envVar("CLOUD_PASSWORD") ?? await promptForPhrase("Password: ")
567
573
  });
568
574
  if (json) {
569
575
  print(JSON.stringify(account, null, 2));
@@ -1155,7 +1161,7 @@ async function main() {
1155
1161
  return;
1156
1162
  }
1157
1163
  if (action === "run") {
1158
- const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Pass --recovery-phrase, set VAULTLINE_RECOVERY_PHRASE, or run `sealkeep autopilot` so this machine remembers it.");
1164
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Pass --recovery-phrase, set SEALKEEP_RECOVERY_PHRASE, or run `sealkeep autopilot` so this machine remembers it.");
1159
1165
  const processed = await drainQueue(dataDir, phrase, { max: Number(take(args, "--max", "25")) });
1160
1166
  if (json) {
1161
1167
  print(JSON.stringify(processed, null, 2));
@@ -1563,7 +1569,7 @@ async function main() {
1563
1569
  return;
1564
1570
  }
1565
1571
  if (command === "migrate") {
1566
- const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set VAULTLINE_RECOVERY_PHRASE)");
1572
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set SEALKEEP_RECOVERY_PHRASE)");
1567
1573
  const result = await migrateVault(dataDir, phrase);
1568
1574
  if (json) {
1569
1575
  print(JSON.stringify(result, null, 2));
@@ -1573,7 +1579,7 @@ async function main() {
1573
1579
  return;
1574
1580
  }
1575
1581
  if (command === "rewrap") {
1576
- const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set VAULTLINE_RECOVERY_PHRASE)");
1582
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set SEALKEEP_RECOVERY_PHRASE)");
1577
1583
  const result = await rewrapVault(dataDir, phrase, { group: take(args, "--group") });
1578
1584
  if (json) {
1579
1585
  print(JSON.stringify(result, null, 2));
@@ -1757,8 +1763,8 @@ async function main() {
1757
1763
  const backend = take(args, "--backend");
1758
1764
  // The account's shared Google client first: no console visit, no env
1759
1765
  // var — the consent screen is the whole setup. The PKCE desktop path
1760
- // stays for self-hosters who set VAULTLINE_GDRIVE_CLIENT_ID.
1761
- const viaCloud = !process.env.VAULTLINE_GDRIVE_CLIENT_ID;
1766
+ // stays for self-hosters who set SEALKEEP_GDRIVE_CLIENT_ID.
1767
+ const viaCloud = !envVar("GDRIVE_CLIENT_ID");
1762
1768
  const credentials = viaCloud ? await (await import("./cloud.js")).connectGdriveViaCloud(dataDir, {
1763
1769
  onConsentUrl: (url) => {
1764
1770
  print(`\n ${dim("Approve Sealkeep in the browser window. If none opened, use this link:")}`);
@@ -1982,7 +1988,7 @@ async function main() {
1982
1988
  fail("invalid_argument", "Usage: sealkeep retention <preview|apply|prune|offload|approve|policy> [options]");
1983
1989
  }
1984
1990
  if (command === "daemon") {
1985
- const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Run `sealkeep autopilot` so this machine can unlock itself, or set VAULTLINE_RECOVERY_PHRASE.");
1991
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Run `sealkeep autopilot` so this machine can unlock itself, or set SEALKEEP_RECOVERY_PHRASE.");
1986
1992
  const reclaim = args.includes("--reclaim");
1987
1993
  const daemon = await startDaemon(dataDir, {
1988
1994
  phrase, intervalMs: Number(take(args, "--interval", "30")) * 1000, home: take(args, "--home"),
@@ -2012,10 +2018,10 @@ async function main() {
2012
2018
  }
2013
2019
  const HINTS = {
2014
2020
  vault_not_initialized: "run `sealkeep quickstart`",
2015
- recovery_phrase_missing: "pass --recovery-phrase, or set VAULTLINE_RECOVERY_PHRASE",
2021
+ recovery_phrase_missing: "pass --recovery-phrase, or set SEALKEEP_RECOVERY_PHRASE",
2016
2022
  recovery_phrase_mismatch: "check the phrase from your recovery kit",
2017
2023
  storage_not_configured: "run `sealkeep storage configure --provider … --bucket … --prefix …`",
2018
- signer_not_configured: "set VAULTLINE_ENABLE_SIGNER=1 and store credentials",
2024
+ signer_not_configured: "set SEALKEEP_ENABLE_SIGNER=1 and store credentials",
2019
2025
  destination_exists: "add --overwrite backup to keep the existing file",
2020
2026
  destination_unwritable: "free some space, or restore to a different <destination> on another volume",
2021
2027
  archive_not_found: "run `sealkeep list` to see archive ids",
package/dist/src/cloud.js CHANGED
@@ -6,6 +6,7 @@ import { canonicalPhrase } from "./mnemonic.js";
6
6
  import { fail, isVaultlineError, VaultlineError } from "./errors.js";
7
7
  import { sha256 } from "./crypto.js";
8
8
  import { listArchives, readConfig, writeRecord } from "./vault.js";
9
+ import { envVar } from "./env.js";
9
10
  /**
10
11
  * Sealkeep Cloud: the managed tier.
11
12
  *
@@ -16,7 +17,7 @@ import { listArchives, readConfig, writeRecord } from "./vault.js";
16
17
  * control plane is never sent a key that opens it.
17
18
  */
18
19
  export const DEFAULT_CLOUD_URL = "https://shared.spala.ai/p04946/api";
19
- const cloudUrl = (env = process.env) => (env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
20
+ const cloudUrl = (env = process.env) => (envVar("CLOUD_URL", env) ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
20
21
  /**
21
22
  * Where this vault's cloud token lives in the keystore.
22
23
  *
@@ -4,18 +4,19 @@ import { join } from "node:path";
4
4
  import { createControlPlaneServer } from "./control-plane/server.js";
5
5
  import { defaultDataDir } from "./vault.js";
6
6
  import { freePort } from "./net.js";
7
+ import { envVar } from "./env.js";
7
8
  /**
8
9
  * Runs the control plane.
9
10
  *
10
- * Authentication is on by default. `VAULTLINE_CONTROL_PLANE_DEV=1` turns it off for
11
+ * Authentication is on by default. `SEALKEEP_CONTROL_PLANE_DEV=1` turns it off for
11
12
  * local interface work and the banner says so loudly, because an unauthenticated
12
13
  * instance reachable from a network would let anyone enumerate account metadata.
13
14
  */
14
- const development = process.env.VAULTLINE_CONTROL_PLANE_DEV === "1";
15
- const storePath = process.env.VAULTLINE_CONTROL_PLANE_STORE ?? join(defaultDataDir(), "control-plane", "state.json");
16
- const tls = process.env.VAULTLINE_TLS_CERT && process.env.VAULTLINE_TLS_KEY
17
- ? { cert: readFileSync(process.env.VAULTLINE_TLS_CERT), key: readFileSync(process.env.VAULTLINE_TLS_KEY) }
18
- : undefined;
15
+ const development = envVar("CONTROL_PLANE_DEV") === "1";
16
+ const storePath = envVar("CONTROL_PLANE_STORE") ?? join(defaultDataDir(), "control-plane", "state.json");
17
+ const tlsCert = envVar("TLS_CERT");
18
+ const tlsKey = envVar("TLS_KEY");
19
+ const tls = tlsCert && tlsKey ? { cert: readFileSync(tlsCert), key: readFileSync(tlsKey) } : undefined;
19
20
  const server = createControlPlaneServer({
20
21
  storePath: development ? undefined : storePath,
21
22
  requireAuth: !development,
@@ -31,7 +32,7 @@ async function listen() {
31
32
  if (development)
32
33
  console.log("MODE: development — device authentication is DISABLED and state is in memory. Do not expose this.");
33
34
  else
34
- console.log(`Durable state: ${storePath}${tls ? "" : "\nWARNING: no TLS configured. Set VAULTLINE_TLS_CERT and VAULTLINE_TLS_KEY before accepting non-loopback traffic."}`);
35
+ console.log(`Durable state: ${storePath}${tls ? "" : "\nWARNING: no TLS configured. Set SEALKEEP_TLS_CERT and SEALKEEP_TLS_KEY before accepting non-loopback traffic."}`);
35
36
  });
36
37
  }
37
38
  void listen();
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { fail } from "./errors.js";
3
+ import { envVar } from "./env.js";
3
4
  export const DEFAULT_LEASE_TTL_MS = 15 * 60_000;
4
5
  class PlannedProvider {
5
6
  provider;
@@ -44,13 +45,13 @@ export function assertLeaseUsable(lease, now = Date.now()) {
44
45
  }
45
46
  /** Feature flag for provider work in progress. Off by default and read per call so tests and operators can flip it. */
46
47
  export function signerEnabled(env = process.env) {
47
- return env.VAULTLINE_ENABLE_SIGNER === "1";
48
+ return envVar("ENABLE_SIGNER", env) === "1";
48
49
  }
49
50
  const uploadClients = new Map();
50
51
  /** Registration is refused unless the operator explicitly enabled signer work. */
51
52
  export function registerUploadClient(client, env = process.env) {
52
53
  if (!signerEnabled(env))
53
- fail("signer_not_configured", "Set VAULTLINE_ENABLE_SIGNER=1 to register a provider upload client", { provider: client.kind });
54
+ fail("signer_not_configured", "Set SEALKEEP_ENABLE_SIGNER=1 to register a provider upload client", { provider: client.kind });
54
55
  uploadClients.set(client.kind, client);
55
56
  }
56
57
  export function uploadClientFor(kind, env = process.env) {
@@ -5,7 +5,8 @@
5
5
  import { defaultDataDir } from "./vault.js";
6
6
  import { createLocalApiServer, localApiToken, localApiTokenPath } from "./local-api.js";
7
7
  import { freePort } from "./net.js";
8
- const dataDir = process.env.VAULTLINE_DATA_DIR ?? defaultDataDir();
8
+ import { envVar } from "./env.js";
9
+ const dataDir = envVar("DATA_DIR") ?? defaultDataDir();
9
10
  async function listen() {
10
11
  const token = await localApiToken(dataDir);
11
12
  const server = createLocalApiServer(dataDir, token);
@@ -10,6 +10,7 @@ import { quotaMessage, quotaState } from "./packages.js";
10
10
  import { loadProviderCredentials, resolveRecoveryPhrase } from "./secrets.js";
11
11
  import { ENVELOPE_VERSION, SUITES } from "../packages/vaultline-crypto/src/index.js";
12
12
  import { readConfig } from "./vault.js";
13
+ import { envVar } from "./env.js";
13
14
  async function writable(directory) {
14
15
  const probe = join(directory, `.vaultline-write-probe-${randomUUID()}`);
15
16
  try {
@@ -175,7 +176,7 @@ export async function runDoctor(dataDir, env = process.env) {
175
176
  // missing and named an environment variable as the cure.
176
177
  const phraseAvailable = config
177
178
  ? await resolveRecoveryPhrase(dataDir, config.vaultId, undefined, env).then((phrase) => Boolean(phrase)).catch(() => false)
178
- : Boolean(env.VAULTLINE_RECOVERY_PHRASE);
179
+ : Boolean(envVar("RECOVERY_PHRASE", env));
179
180
  checks.push(phraseAvailable
180
181
  ? { name: "recovery-phrase", status: "pass", detail: "This machine can unlock the vault on its own, so archiving runs unattended." }
181
182
  : { 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." });
@@ -18,7 +18,7 @@ import { setRetentionPolicy, retentionSettings, DEFAULT_RETENTION } from "./rete
18
18
  * injectable — this module has to be testable against a fake today and
19
19
  * work unchanged against the real thing later.
20
20
  */
21
- const cloudUrl = (env = process.env) => (env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
21
+ const cloudUrl = (env = process.env) => (env.SEALKEEP_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
22
22
  /**
23
23
  * The token location comes from cloud.ts rather than being restated here.
24
24
  *
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Environment variables, under the product's own name.
3
+ *
4
+ * The product was called Vaultline until August 2026, and its variables carried
5
+ * that name. They still work — someone's launchd plist or CI file should not
6
+ * break because we renamed a brand — but everything the product prints, writes,
7
+ * and documents now says SEALKEEP_. When both are set the new name wins, so a
8
+ * machine can be migrated one variable at a time.
9
+ */
10
+ /** Reads `SEALKEEP_<name>`, falling back to the Vaultline-era name. */
11
+ export declare function envVar(name: string, env?: NodeJS.ProcessEnv): string | undefined;
12
+ /** True when either spelling is set to something truthy. */
13
+ export declare function envFlag(name: string, env?: NodeJS.ProcessEnv): boolean;
14
+ /** The name to print or write into a config file — always the current one. */
15
+ export declare const envName: (name: string) => string;
16
+ /** Both spellings, for messages that tell someone what to set. */
17
+ export declare const envNames: (name: string) => string[];
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Environment variables, under the product's own name.
3
+ *
4
+ * The product was called Vaultline until August 2026, and its variables carried
5
+ * that name. They still work — someone's launchd plist or CI file should not
6
+ * break because we renamed a brand — but everything the product prints, writes,
7
+ * and documents now says SEALKEEP_. When both are set the new name wins, so a
8
+ * machine can be migrated one variable at a time.
9
+ */
10
+ const LEGACY_PREFIX = "VAULTLINE_";
11
+ const PREFIX = "SEALKEEP_";
12
+ /** Reads `SEALKEEP_<name>`, falling back to the Vaultline-era name. */
13
+ export function envVar(name, env = process.env) {
14
+ const current = env[PREFIX + name];
15
+ if (current !== undefined && current !== "")
16
+ return current;
17
+ const legacy = env[LEGACY_PREFIX + name];
18
+ return legacy === "" ? undefined : legacy;
19
+ }
20
+ /** True when either spelling is set to something truthy. */
21
+ export function envFlag(name, env = process.env) {
22
+ const value = envVar(name, env);
23
+ return value === "1" || value === "true" || value === "yes";
24
+ }
25
+ /** The name to print or write into a config file — always the current one. */
26
+ export const envName = (name) => PREFIX + name;
27
+ /** Both spellings, for messages that tell someone what to set. */
28
+ export const envNames = (name) => [PREFIX + name, LEGACY_PREFIX + name];
@@ -3,7 +3,7 @@
3
3
  * between releases; these codes are a contract for the CLI, local API, and MCP.
4
4
  * Details are operational only: never place plaintext, phrases, or keys here.
5
5
  */
6
- export type VaultlineErrorCode = "invalid_argument" | "vault_not_initialized" | "vault_already_initialized" | "config_unsupported_version" | "recovery_phrase_missing" | "recovery_phrase_mismatch" | "archive_not_found" | "source_unreadable" | "destination_exists" | "destination_unwritable" | "path_not_permitted" | "native_restore_unavailable" | "ciphertext_integrity_failed" | "plaintext_integrity_failed" | "hook_payload_invalid" | "queue_job_not_found" | "queue_lease_invalid" | "queue_lease_expired" | "queue_job_exhausted" | "storage_not_configured" | "provider_unsupported" | "signer_not_configured" | "lease_expired" | "unauthorized" | "forbidden" | "internal";
6
+ export type VaultlineErrorCode = "invalid_argument" | "vault_not_initialized" | "vault_already_initialized" | "config_unsupported_version" | "recovery_phrase_missing" | "recovery_phrase_mismatch" | "archive_not_found" | "source_unreadable" | "destination_exists" | "destination_unwritable" | "insufficient_disk_space" | "path_not_permitted" | "native_restore_unavailable" | "ciphertext_integrity_failed" | "plaintext_integrity_failed" | "hook_payload_invalid" | "queue_job_not_found" | "queue_lease_invalid" | "queue_lease_expired" | "queue_job_exhausted" | "storage_not_configured" | "provider_unsupported" | "signer_not_configured" | "lease_expired" | "unauthorized" | "forbidden" | "internal";
7
7
  export declare class VaultlineError extends Error {
8
8
  readonly code: VaultlineErrorCode;
9
9
  readonly details: Record<string, unknown>;
@@ -24,6 +24,7 @@ import { isV2 } from "./types.js";
24
24
  import * as cloud from "./cloud.js";
25
25
  import { configureRemoteStorage, initialize, listArchives, previewRetention, readConfig, vaultStatus, writeRecord } from "./vault.js";
26
26
  import { sha256 } from "./crypto.js";
27
+ import { envVar } from "./env.js";
27
28
  const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
28
29
  /**
29
30
  * `vault.line` and `vault.localhost` join the list because the address bar is
@@ -303,7 +304,7 @@ export async function testStorageTarget(input) {
303
304
  const payload = Buffer.from(`sealkeep write test ${randomBytes(16).toString("hex")}\n`);
304
305
  const digest = sha256(payload);
305
306
  try {
306
- // VAULTLINE_ENABLE_SIGNER gates *uploading archives* through a provider
307
+ // SEALKEEP_ENABLE_SIGNER gates *uploading archives* through a provider
307
308
  // client, and it is off unless the installed service turns it on. That gate
308
309
  // is right for archives and wrong for this: refusing to check a bucket
309
310
  // because archives are not enabled yet would mean the only way to find out
@@ -312,7 +313,7 @@ export async function testStorageTarget(input) {
312
313
  // fifty bytes the person explicitly asked to have written, to a bucket they
313
314
  // just named, with a credential they just typed. Nothing else in this
314
315
  // process gains the flag and the archive path is untouched.
315
- const probeEnv = { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" };
316
+ const probeEnv = { ...process.env, SEALKEEP_ENABLE_SIGNER: "1" };
316
317
  const target = { provider, bucket, prefix, ...(region ? { region } : {}) };
317
318
  const client = createUploadClient(target, credentials, endpoint ? endpointOverrides(endpoint) : {}, probeEnv);
318
319
  const lease = createActiveLease(target, {
@@ -648,12 +649,13 @@ async function cloudPlanAndQuota(dataDir) {
648
649
  /**
649
650
  * The account panel lives on the product domain, which is no longer the API's
650
651
  * origin — deriving it from the cloud URL sent people to the old address after
651
- * the rename. A self-hosted plane (VAULTLINE_CLOUD_URL set) still derives from
652
+ * the rename. A self-hosted plane (SEALKEEP_CLOUD_URL set) still derives from
652
653
  * its own origin, because there is no sealkeep.spala.ai panel for it.
653
654
  */
654
655
  async function panelUrl() {
655
- if (process.env.VAULTLINE_CLOUD_URL) {
656
- const origin = process.env.VAULTLINE_CLOUD_URL.replace(/\/+$/, "").replace(/\/api$/, "");
656
+ const selfHosted = envVar("CLOUD_URL");
657
+ if (selfHosted) {
658
+ const origin = selfHosted.replace(/\/+$/, "").replace(/\/api$/, "");
657
659
  return `${origin}/#account`;
658
660
  }
659
661
  const { PRODUCT_URL } = await import("./branding.js");
@@ -950,7 +952,7 @@ export function createLocalApiServer(dataDir, token, options = {}) {
950
952
  if (offerCache && Date.now() - offerCache.at < 10 * 60_000)
951
953
  return offerCache.value;
952
954
  try {
953
- const base = (process.env.VAULTLINE_CLOUD_URL ?? cloud.DEFAULT_CLOUD_URL).replace(/\/$/, "");
955
+ const base = (envVar("CLOUD_URL") ?? cloud.DEFAULT_CLOUD_URL).replace(/\/$/, "");
954
956
  const response = await fetch(`${base}/v1/public/offer`, { signal: AbortSignal.timeout(4000) });
955
957
  const body = response.ok ? await response.json() : null;
956
958
  offerCache = { at: Date.now(), value: body?.offer ?? null };
@@ -1500,7 +1502,7 @@ export function createLocalApiServer(dataDir, token, options = {}) {
1500
1502
  const { capGb } = parseBody(rollingCapSchema, await readJsonBody(request));
1501
1503
  const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
1502
1504
  const token = await cloudToken(dataDir);
1503
- const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1505
+ const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
1504
1506
  const pushed = await fetch(`${base}/v1/cloud/rolling`, {
1505
1507
  method: "PUT",
1506
1508
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
@@ -1596,11 +1598,11 @@ export function createLocalApiServer(dataDir, token, options = {}) {
1596
1598
  remembered = await rememberRecoveryPhrase(dataDir, config.vaultId, phrase);
1597
1599
  }
1598
1600
  catch (error) {
1599
- notes.push(`This machine's keystore refused the phrase (${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}). The background service will need VAULTLINE_RECOVERY_PHRASE in its environment.`);
1601
+ notes.push(`This machine's keystore refused the phrase (${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}). The background service will need SEALKEEP_RECOVERY_PHRASE in its environment.`);
1600
1602
  }
1601
1603
  }
1602
1604
  else {
1603
- notes.push("The phrase was not stored on this machine, so the background service cannot seal sessions until VAULTLINE_RECOVERY_PHRASE is in its environment.");
1605
+ notes.push("The phrase was not stored on this machine, so the background service cannot seal sessions until SEALKEEP_RECOVERY_PHRASE is in its environment.");
1604
1606
  }
1605
1607
  if (password) {
1606
1608
  // Failure may not cost the person this response: the vault already
@@ -1748,7 +1750,7 @@ export function createLocalApiServer(dataDir, token, options = {}) {
1748
1750
  // verified remote copy it would never free a byte either — see the
1749
1751
  // note on `environment` in service.ts. serviceUnitEnvironment adds
1750
1752
  // the secret-backend choice, without which the daemon cannot unlock.
1751
- environment: serviceUnitEnvironment(config.remoteStorage ? { VAULTLINE_ENABLE_SIGNER: "1" } : {})
1753
+ environment: serviceUnitEnvironment(config.remoteStorage ? { SEALKEEP_ENABLE_SIGNER: "1" } : {})
1752
1754
  }));
1753
1755
  return json(response, outcome.installed ? 201 : 200, {
1754
1756
  service: { kind: outcome.kind, installed: outcome.installed, alreadyInstalled: false, path: outcome.path, ranCommands: outcome.ranCommands, note: outcome.note ?? null },
@@ -30,7 +30,7 @@ export declare function mcpServerSpec(dataDir: string, executable?: string): {
30
30
  command: string;
31
31
  args: string[];
32
32
  env: {
33
- VAULTLINE_DATA_DIR: string;
33
+ SEALKEEP_DATA_DIR: string;
34
34
  };
35
35
  };
36
36
  /**
@@ -10,7 +10,7 @@ export function mcpServerSpec(dataDir, executable = "vaultline") {
10
10
  return {
11
11
  command: executable,
12
12
  args: ["mcp"],
13
- env: { VAULTLINE_DATA_DIR: dataDir }
13
+ env: { SEALKEEP_DATA_DIR: dataDir }
14
14
  };
15
15
  }
16
16
  /**
@@ -44,7 +44,7 @@ async function installForClaude(dataDir, exec = run) {
44
44
  try {
45
45
  await exec("claude", [
46
46
  "mcp", "add", "--transport", "stdio", "--scope", "user", "vaultline",
47
- "--env", `VAULTLINE_DATA_DIR=${dataDir}`,
47
+ "--env", `SEALKEEP_DATA_DIR=${dataDir}`,
48
48
  "--", spec.command, ...spec.args
49
49
  ]);
50
50
  return { installed: true, detail: "registered with Claude Code — ask it to search your sessions" };
@@ -74,7 +74,7 @@ async function installForCodex(dataDir, home) {
74
74
  `args = ${JSON.stringify(spec.args)}`,
75
75
  "",
76
76
  "[mcp_servers.vaultline.env]",
77
- `VAULTLINE_DATA_DIR = ${JSON.stringify(dataDir)}`,
77
+ `SEALKEEP_DATA_DIR = ${JSON.stringify(dataDir)}`,
78
78
  ""
79
79
  ].join("\n");
80
80
  await mkdir(dirname(fragmentPath), { recursive: true });
package/dist/src/mcp.js CHANGED
@@ -4,12 +4,13 @@ import { z } from "zod";
4
4
  import { archiveFile, defaultDataDir, listArchives, previewRetention, vaultStatus } from "./vault.js";
5
5
  import { restoreArchive } from "./restore.js";
6
6
  import { providers } from "./control-plane.js";
7
- const dataDir = process.env.VAULTLINE_DATA_DIR ?? defaultDataDir();
8
- const phrase = process.env.VAULTLINE_RECOVERY_PHRASE;
7
+ import { envVar } from "./env.js";
8
+ const dataDir = envVar("DATA_DIR") ?? defaultDataDir();
9
+ const phrase = envVar("RECOVERY_PHRASE");
9
10
  const server = new McpServer({ name: "vaultline", version: "0.1.0" });
10
11
  function text(value) { return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }; }
11
12
  function requirePhrase() { if (!phrase)
12
- throw new Error("VAULTLINE_RECOVERY_PHRASE is required for archive and recovery actions"); return phrase; }
13
+ throw new Error("SEALKEEP_RECOVERY_PHRASE is required for archive and recovery actions"); return phrase; }
13
14
  server.registerTool("vaultline_status", {
14
15
  title: "Sealkeep status",
15
16
  description: "Read local Sealkeep health, archive count, and encrypted storage usage. No session content is returned.",
@@ -1,9 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { platform } from "node:os";
3
+ import { envVar } from "./env.js";
3
4
  const run = (file, args) => new Promise((resolve) => execFile(file, args, () => resolve()));
4
5
  /** Off by an explicit opt-out, and off automatically where there is no desktop session. */
5
6
  export function notificationsEnabled(env = process.env, target = platform()) {
6
- if (env.VAULTLINE_NOTIFICATIONS === "off")
7
+ if (envVar("NOTIFICATIONS", env) === "off")
7
8
  return false;
8
9
  if (target === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
9
10
  return false;
@@ -116,7 +116,7 @@ export async function offloadArchives(dataDir, options = {}) {
116
116
  /**
117
117
  * Downloads an archive's ciphertext from the bucket it was offloaded to.
118
118
  *
119
- * VAULTLINE_ENABLE_SIGNER gates *uploading*, and it is off unless the installed
119
+ * SEALKEEP_ENABLE_SIGNER gates *uploading*, and it is off unless the installed
120
120
  * service turns it on. Reading back an archive in order to restore it is not
121
121
  * uploading, and refusing it on that flag would mean an offloaded archive could
122
122
  * not be recovered by the person who owns it — which would turn a space-saving
@@ -191,7 +191,7 @@ async function fetchCiphertext(dataDir, record, client) {
191
191
  const resolved = client ?? await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
192
192
  const lease = createActiveLease(target, {
193
193
  archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.cipher.storedBytes
194
- }, { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" });
194
+ }, { ...process.env, SEALKEEP_ENABLE_SIGNER: "1" });
195
195
  const readable = resolved;
196
196
  if (typeof readable.download !== "function") {
197
197
  fail("provider_unsupported", `${target.provider} cannot be read back by this build, so this archive cannot be fetched`);
@@ -257,7 +257,7 @@ export async function checkRemoteCopy(dataDir, archiveId, client) {
257
257
  const resolved = client ?? await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
258
258
  const lease = createActiveLease(target, {
259
259
  archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.cipher.storedBytes
260
- }, { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" });
260
+ }, { ...process.env, SEALKEEP_ENABLE_SIGNER: "1" });
261
261
  const probing = resolved;
262
262
  if (typeof probing.head !== "function")
263
263
  fail("provider_unsupported", `${target.provider} cannot be probed by this build`);
@@ -320,6 +320,19 @@ export async function checkRemoteCopyMany(dataDir, ids, client) {
320
320
  export async function materialiseArchive(dataDir, record, client) {
321
321
  if (!isV2(record) || !record.offloaded)
322
322
  return { path: record.objectPath, release: async () => { } };
323
+ // Reading an offloaded archive means writing it back to disk in full, and
324
+ // this runs in a loop during an index build. On a machine that offloaded
325
+ // BECAUSE it was short of space, that filled the disk and died on ENOSPC
326
+ // mid-build — the exact failure this product exists to prevent. Ask first,
327
+ // and refuse in words that name the archive and the shortfall.
328
+ const { freeBytes } = await import("./disk.js");
329
+ const free = await freeBytes(tmpdir());
330
+ const needed = record.cipher.storedBytes ?? record.remote?.bytes ?? 0;
331
+ const RESERVE = 512 * 1024 * 1024;
332
+ if (free !== undefined && needed > 0 && free < needed + RESERVE) {
333
+ const gb = (n) => `${(n / 1024 ** 3).toFixed(1)} GB`;
334
+ fail("insufficient_disk_space", `Reading archive ${record.id.slice(0, 8)} needs about ${gb(needed)} of temporary space and this machine has ${gb(free)} free. Nothing was written. Free some space, or reclaim what is already archived, and try again.`, { archiveId: record.id, free, needed });
335
+ }
323
336
  const ciphertext = await fetchCiphertext(dataDir, record, client);
324
337
  if (sha256(ciphertext) !== record.cipher.ciphertextSha256) {
325
338
  fail("ciphertext_integrity_failed", `The copy of archive ${record.id} in ${record.offloaded.bucket} is not the archive that was stored there`, { archiveId: record.id });
@@ -16,7 +16,7 @@ import type { ProviderKind, ProviderUploadClient, UploadLease } from "../control
16
16
  * into the OS keystore via secrets.ts and never into a config file.
17
17
  */
18
18
  export declare const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
19
- /** Placeholder until a first-party client id ships. Point VAULTLINE_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
19
+ /** Placeholder until a first-party client id ships. Point SEALKEEP_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
20
20
  export declare const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
21
21
  export declare function gdriveClientId(env?: NodeJS.ProcessEnv): string;
22
22
  export declare const GDRIVE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
4
  import { fail, VaultlineError } from "../errors.js";
5
+ import { envVar } from "../env.js";
5
6
  /**
6
7
  * Google Drive as a bring-your-own storage provider (Drive API v3).
7
8
  *
@@ -19,10 +20,10 @@ import { fail, VaultlineError } from "../errors.js";
19
20
  * into the OS keystore via secrets.ts and never into a config file.
20
21
  */
21
22
  export const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
22
- /** Placeholder until a first-party client id ships. Point VAULTLINE_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
23
+ /** Placeholder until a first-party client id ships. Point SEALKEEP_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
23
24
  export const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
24
25
  export function gdriveClientId(env = process.env) {
25
- return env.VAULTLINE_GDRIVE_CLIENT_ID?.trim() || GDRIVE_CLIENT_ID_PLACEHOLDER;
26
+ return envVar("GDRIVE_CLIENT_ID", env)?.trim() || GDRIVE_CLIENT_ID_PLACEHOLDER;
26
27
  }
27
28
  export const GDRIVE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
28
29
  export const GDRIVE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -18,7 +18,7 @@ function isGdriveCredentials(credentials) {
18
18
  */
19
19
  export function createUploadClient(config, credentials, overrides = {}, env = process.env) {
20
20
  if (!signerEnabled(env))
21
- fail("signer_not_configured", "Uploads are disabled. Set VAULTLINE_ENABLE_SIGNER=1 to enable a configured provider client.", { provider: config.provider });
21
+ fail("signer_not_configured", "Uploads are disabled. Set SEALKEEP_ENABLE_SIGNER=1 to enable a configured provider client.", { provider: config.provider });
22
22
  if (config.provider === "gdrive") {
23
23
  if (!isGdriveCredentials(credentials))
24
24
  fail("invalid_argument", "Google Drive needs an OAuth credential. Run: sealkeep storage connect gdrive");
@@ -29,7 +29,7 @@ export async function rehydrateSession(dataDir, target, phrase, options = {}) {
29
29
  return { rehydrated: false, reason: "already-present" };
30
30
  }
31
31
  if (!phrase)
32
- return { rehydrated: false, reason: "no-phrase", note: "No recovery phrase is available to this machine (keystore or VAULTLINE_RECOVERY_PHRASE), so the sealed copy stays sealed." };
32
+ return { rehydrated: false, reason: "no-phrase", note: "No recovery phrase is available to this machine (keystore or SEALKEEP_RECOVERY_PHRASE), so the sealed copy stays sealed." };
33
33
  try {
34
34
  const outcome = await restoreArchive(dataDir, newest.id, phrase, { native: true, overwrite: "refuse", home: options.home });
35
35
  await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: newest.id, path: outcome.output, rehydrated: true });
@@ -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.`,
@@ -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;
@@ -82,7 +83,7 @@ export async function pushTargetsToCloud(dataDir, targets) {
82
83
  }
83
84
  const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
84
85
  const token = await cloudToken(dataDir);
85
- const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
86
+ const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
86
87
  const response = await fetch(`${base}/v1/cloud/storage-targets`, {
87
88
  method: "PUT",
88
89
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
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.3",
3
+ "version": "0.6.1",
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",