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.
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +30 -0
- package/CONTROL_PLANE.md +3 -3
- package/README.md +3 -3
- package/dist/site/index.html +287 -178
- package/dist/site.zip +0 -0
- package/dist/src/adapters.js +2 -2
- package/dist/src/autopilot.js +3 -3
- package/dist/src/chunk-store.js +6 -0
- package/dist/src/cli.js +22 -16
- package/dist/src/cloud.js +2 -1
- package/dist/src/control-plane-cli.js +8 -7
- package/dist/src/control-plane.js +3 -2
- package/dist/src/dashboard-cli.js +2 -1
- package/dist/src/doctor.js +2 -1
- package/dist/src/enroll.js +1 -1
- package/dist/src/env.d.ts +17 -0
- package/dist/src/env.js +28 -0
- package/dist/src/local-api.js +12 -10
- package/dist/src/mcp-install.d.ts +1 -1
- package/dist/src/mcp-install.js +3 -3
- package/dist/src/mcp.js +4 -3
- package/dist/src/notify.js +2 -1
- package/dist/src/offload.js +3 -3
- package/dist/src/providers/gdrive.d.ts +1 -1
- package/dist/src/providers/gdrive.js +3 -2
- package/dist/src/providers/index.js +1 -1
- package/dist/src/rehydrate.js +1 -1
- package/dist/src/search.js +53 -0
- package/dist/src/secrets.d.ts +1 -1
- package/dist/src/secrets.js +4 -3
- package/dist/src/service.d.ts +2 -2
- package/dist/src/service.js +4 -2
- package/dist/src/storage-setup.js +5 -5
- package/dist/src/storage-targets.d.ts +3 -0
- package/dist/src/storage-targets.js +22 -2
- package/dist/src/types.d.ts +3 -0
- package/dist/src/upload.js +4 -0
- package/dist/src/vault.js +4 -3
- package/package.json +4 -2
package/dist/site.zip
CHANGED
|
Binary file
|
package/dist/src/adapters.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
/**
|
package/dist/src/autopilot.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ? {
|
|
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/chunk-store.js
CHANGED
|
@@ -407,6 +407,12 @@ async function runChunkSeal(ctx, args) {
|
|
|
407
407
|
indexed = true;
|
|
408
408
|
}
|
|
409
409
|
catch { /* the next `index build` re-indexes this archive */ }
|
|
410
|
+
// The account learns what each destination holds from the machines that
|
|
411
|
+
// write to them — refresh the stamps now that this archive is recorded.
|
|
412
|
+
{
|
|
413
|
+
const { pushTargetUsage } = await import("./storage-targets.js");
|
|
414
|
+
void pushTargetUsage(ctx.dataDir);
|
|
415
|
+
}
|
|
410
416
|
return { record, folder: args.folder, storedBytes: sealed.storedBytes, chunkCount: envelope.chunks.length, reusedChunks: args.reusedChunks, heldAtMostBytes, indexed };
|
|
411
417
|
}
|
|
412
418
|
/**
|
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 ??
|
|
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
|
-
|
|
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
|
|
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(`
|
|
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:
|
|
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:
|
|
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") ??
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1761
|
-
const viaCloud = !
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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. `
|
|
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 =
|
|
15
|
-
const storePath =
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/src/doctor.js
CHANGED
|
@@ -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
|
|
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." });
|
package/dist/src/enroll.js
CHANGED
|
@@ -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.
|
|
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[];
|
package/dist/src/env.js
ADDED
|
@@ -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];
|
package/dist/src/local-api.js
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
656
|
-
|
|
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 = (
|
|
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 = (
|
|
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
|
|
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
|
|
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 ? {
|
|
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 },
|
package/dist/src/mcp-install.js
CHANGED
|
@@ -10,7 +10,7 @@ export function mcpServerSpec(dataDir, executable = "vaultline") {
|
|
|
10
10
|
return {
|
|
11
11
|
command: executable,
|
|
12
12
|
args: ["mcp"],
|
|
13
|
-
env: {
|
|
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", `
|
|
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
|
-
`
|
|
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
|
-
|
|
8
|
-
const
|
|
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("
|
|
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.",
|
package/dist/src/notify.js
CHANGED
|
@@ -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
|
|
7
|
+
if (envVar("NOTIFICATIONS", env) === "off")
|
|
7
8
|
return false;
|
|
8
9
|
if (target === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
9
10
|
return false;
|
package/dist/src/offload.js
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
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,
|
|
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`);
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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");
|
package/dist/src/rehydrate.js
CHANGED
|
@@ -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
|
|
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 });
|