sealkeep 0.9.0 → 0.11.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 +163 -14
- package/CHANGELOG.md +223 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/worker.js
CHANGED
|
@@ -1,25 +1,77 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
1
|
import { stat } from "node:fs/promises";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
2
|
import { hostname } from "node:os";
|
|
5
3
|
import { resolve } from "node:path";
|
|
6
4
|
import { ArchiveQueue } from "./queue.js";
|
|
7
5
|
import { archiveFile, listArchives, readConfig } from "./vault.js";
|
|
8
|
-
import {
|
|
9
|
-
import { roomToArchive, sealReservations, spaceNeededFor } from "./disk.js";
|
|
6
|
+
import { isSealkeepError } from "./errors.js";
|
|
7
|
+
import { LOCAL_SEAL_CHUNK_BYTES, roomToArchive, sealReservations, spaceNeededFor } from "./disk.js";
|
|
10
8
|
import { isV2 } from "./types.js";
|
|
9
|
+
import { automaticTranscriptIsEnabled, backgroundUploadBytesPerSecond, readLocalSettings } from "./machine-settings.js";
|
|
10
|
+
import { hashFileRange } from "../packages/sealkeep-crypto/src/index.js";
|
|
11
|
+
import { createProgressDeadline } from "./progress-deadline.js";
|
|
12
|
+
import { readUntouchedCodexResumeStub } from "./resume-stub.js";
|
|
13
|
+
import { createBackgroundCpuYield } from "./background-cpu.js";
|
|
11
14
|
/** Parallel seals are capped whatever the config asks: sealing is disk- and CPU-bound, and a machine short of space is rarely long on either. */
|
|
12
15
|
export const MAX_SEAL_CONCURRENCY = 4;
|
|
16
|
+
/** Long enough for a slow local chunk, short enough to expire before the
|
|
17
|
+
* ordinary five-minute queue lease can be mistaken for useful work. */
|
|
18
|
+
export const DEFAULT_SEAL_IDLE_MS = 2 * 60_000;
|
|
13
19
|
/** Operational summary only: codes and messages, never transcript content. */
|
|
14
20
|
function summarize(error) {
|
|
15
|
-
if (
|
|
21
|
+
if (isSealkeepError(error))
|
|
16
22
|
return { code: error.code, message: error.message };
|
|
17
23
|
return { code: "internal", message: error instanceof Error ? error.message : "Unexpected error" };
|
|
18
24
|
}
|
|
19
|
-
const sha256OfFile = (path) =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
const sha256OfFile = async (path, end, onActivity, signal, onYield) => (await hashFileRange(path, 0, end, onActivity, signal, onYield)).sha256;
|
|
26
|
+
/**
|
|
27
|
+
* Keeps one queue lease alive through hashing, compression, encryption and
|
|
28
|
+
* upload. Progress and renewal both rewrite the same file-backed job, so they
|
|
29
|
+
* share one promise lane: otherwise a late progress write could overwrite a
|
|
30
|
+
* newer expiry, or even put `leased` back after `complete` wrote `done`.
|
|
31
|
+
*/
|
|
32
|
+
function keepLeaseAlive(queue, jobId, leaseId, options) {
|
|
33
|
+
let stopped = false;
|
|
34
|
+
let writes = Promise.resolve();
|
|
35
|
+
let firstError;
|
|
36
|
+
let lastLeaseWriteAt = Date.now();
|
|
37
|
+
const deadline = createProgressDeadline({
|
|
38
|
+
operation: `Sealing queue job ${jobId}`,
|
|
39
|
+
idleMs: options.idleMs,
|
|
40
|
+
signal: options.signal,
|
|
41
|
+
});
|
|
42
|
+
const schedule = (write) => {
|
|
43
|
+
if (stopped)
|
|
44
|
+
return;
|
|
45
|
+
writes = writes.then(async () => { await write(); }).catch((error) => { firstError ??= error; });
|
|
46
|
+
};
|
|
47
|
+
const renewForActivity = () => {
|
|
48
|
+
deadline.progress();
|
|
49
|
+
const at = Date.now();
|
|
50
|
+
if (at - lastLeaseWriteAt < Math.min(15_000, queue.leaseHeartbeatMs()))
|
|
51
|
+
return;
|
|
52
|
+
lastLeaseWriteAt = at;
|
|
53
|
+
schedule(() => queue.renew(jobId, leaseId));
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
signal: deadline.signal,
|
|
57
|
+
progress(bytes, of) {
|
|
58
|
+
deadline.progress();
|
|
59
|
+
lastLeaseWriteAt = Date.now();
|
|
60
|
+
schedule(() => queue.progress(jobId, leaseId, bytes, of));
|
|
61
|
+
},
|
|
62
|
+
activity: renewForActivity,
|
|
63
|
+
wait(work) { return deadline.wait(work); },
|
|
64
|
+
async stop() {
|
|
65
|
+
if (!stopped) {
|
|
66
|
+
stopped = true;
|
|
67
|
+
deadline.close();
|
|
68
|
+
}
|
|
69
|
+
await writes;
|
|
70
|
+
if (firstError)
|
|
71
|
+
throw firstError;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
23
75
|
/**
|
|
24
76
|
* The pressure valve: a transcript the disk cannot hold a sealed copy of
|
|
25
77
|
* streams STRAIGHT into a chunk folder instead of waiting for a reclaim —
|
|
@@ -28,57 +80,60 @@ const sha256OfFile = (path) => new Promise((done, failed) => {
|
|
|
28
80
|
* local seal, so the ordinary path (local seal, delta chains, offline
|
|
29
81
|
* archiving) is untouched; under pressure, full seals beat no seals.
|
|
30
82
|
*/
|
|
31
|
-
async function streamUnderPressure(dataDir, job, phrase) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// No remoteStorage entry is NOT "no storage": an enrolled managed vault
|
|
38
|
-
// carries a cloud token instead. sealToChunkFolder resolves the target the
|
|
39
|
-
// same way the rest of the product does, and its refusal becomes the reason.
|
|
40
|
-
// The dedup archiveFile would have done: an unchanged source that is
|
|
41
|
-
// already archived completes without sealing — or streaming — anything.
|
|
83
|
+
async function streamUnderPressure(dataDir, job, phrase, options = {}) {
|
|
84
|
+
// Answer the free operation before provider capability. An unchanged source
|
|
85
|
+
// already represented by an archive needs no scratch space and no upload;
|
|
86
|
+
// this is equally true for plain vaults, Drive and an offline managed vault.
|
|
87
|
+
// The former order returned those provider limitations first and left exact,
|
|
88
|
+
// already-verified snapshots stuck in `ready` forever on a full disk.
|
|
42
89
|
const absolute = resolve(job.sourcePath);
|
|
43
90
|
const size = await stat(absolute).then((entry) => entry.size).catch(() => -1);
|
|
44
91
|
const records = await listArchives(dataDir).catch(() => []);
|
|
45
92
|
const candidates = records.filter(isV2).filter((record) => record.source.path === absolute && record.source.bytes === size);
|
|
46
93
|
if (candidates.length > 0) {
|
|
47
|
-
|
|
94
|
+
// Hash exactly the snapshot whose length we compared. A resumed/live
|
|
95
|
+
// transcript can keep appending faster than a low-priority reader; an
|
|
96
|
+
// unbounded stream then chases the writer forever and never reaches the
|
|
97
|
+
// actual bounded seal below.
|
|
98
|
+
const digest = await sha256OfFile(absolute, size, options.onActivity, options.signal, options.onYield).catch(() => null);
|
|
48
99
|
const match = digest ? candidates.find((record) => record.source.sha256 === digest) : undefined;
|
|
49
100
|
if (match)
|
|
50
101
|
return { record: match, deduplicated: true };
|
|
51
102
|
}
|
|
103
|
+
const config = await readConfig(dataDir);
|
|
104
|
+
if ((config.storageMode ?? "sealed") === "plain")
|
|
105
|
+
return { reason: "plain vaults keep readable archives on this disk" };
|
|
106
|
+
if (config.remoteStorage?.provider === "gdrive")
|
|
107
|
+
return { reason: "Google Drive stores archives as single objects, which need local room to build" };
|
|
108
|
+
// No remoteStorage entry is NOT "no storage": an enrolled managed vault
|
|
109
|
+
// carries a cloud token instead. sealToChunkFolder resolves the target the
|
|
110
|
+
// same way the rest of the product does, and its refusal becomes the reason.
|
|
52
111
|
try {
|
|
53
|
-
const {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
:
|
|
57
|
-
|
|
58
|
-
|
|
112
|
+
const { projectIdentityOfRecord } = await import("./adapters.js");
|
|
113
|
+
const identity = await projectIdentityOfRecord({
|
|
114
|
+
source: { path: absolute, agent: job.agent },
|
|
115
|
+
createdAt: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
const { project, projectKey } = identity;
|
|
59
118
|
const { sealToChunkFolder } = await import("./chunk-store.js");
|
|
60
|
-
const
|
|
119
|
+
const { teamRecipientsForProject } = await import("./vault.js");
|
|
120
|
+
const teamRecipients = await teamRecipientsForProject(dataDir, project, projectKey);
|
|
121
|
+
const local = await readLocalSettings(dataDir);
|
|
122
|
+
const outcome = await sealToChunkFolder(dataDir, absolute, phrase, job.agent, {
|
|
123
|
+
project, projectKey, teamRecipients, signal: options.signal,
|
|
124
|
+
// Pressure streaming is automatic work on an already stressed machine.
|
|
125
|
+
// Small chunks bound the otherwise unavoidable putObject burst; the
|
|
126
|
+
// sustained rate follows the same per-machine setting as normal uploads.
|
|
127
|
+
chunkBytes: 4 * 1024 * 1024,
|
|
128
|
+
maxBytesPerSecond: backgroundUploadBytesPerSecond(local),
|
|
129
|
+
onYield: options.onYield,
|
|
130
|
+
});
|
|
61
131
|
return { record: outcome.record, deduplicated: false };
|
|
62
132
|
}
|
|
63
133
|
catch (error) {
|
|
64
134
|
return { reason: summarize(error).message };
|
|
65
135
|
}
|
|
66
136
|
}
|
|
67
|
-
/**
|
|
68
|
-
* A `sealConcurrency` in the vault's config.json, if someone put one there.
|
|
69
|
-
* The field is not part of the typed config yet; readConfig parses and keeps
|
|
70
|
-
* unknown fields, so a hand-added value is visible here. Anything unreadable
|
|
71
|
-
* or non-numeric means the default.
|
|
72
|
-
*/
|
|
73
|
-
async function configuredConcurrency(dataDir) {
|
|
74
|
-
try {
|
|
75
|
-
const config = (await readConfig(dataDir));
|
|
76
|
-
return typeof config.sealConcurrency === "number" ? config.sealConcurrency : undefined;
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
137
|
/**
|
|
83
138
|
* Encrypts queued transcripts. The recovery phrase lives only in this process:
|
|
84
139
|
* it is never written to a job, a log line, or the local API.
|
|
@@ -97,18 +152,95 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
97
152
|
const owner = options.owner ?? `${hostname()}:${process.pid}`;
|
|
98
153
|
const max = options.max ?? 25;
|
|
99
154
|
const reservations = options.reservations ?? sealReservations;
|
|
100
|
-
const requested = options.concurrency ??
|
|
155
|
+
const requested = options.concurrency ?? 1;
|
|
101
156
|
const concurrency = Math.min(MAX_SEAL_CONCURRENCY, Math.max(1, Math.trunc(requested) || 1));
|
|
102
157
|
const processed = [];
|
|
158
|
+
// One announcement pass per DRAIN, not per seal: pushing the account's
|
|
159
|
+
// index slot costs the whole slot every time (58 MB on the founder vault,
|
|
160
|
+
// ~60 s), so a two-session drain used to pay it twice. Every fresh seal in
|
|
161
|
+
// an ACTIVE project is collected here and announced once at the end.
|
|
162
|
+
const sealedByProject = new Map();
|
|
163
|
+
// Hooks are separate short-lived processes from the filesystem watcher, and
|
|
164
|
+
// a job may already be ready when someone adds an exclusion. Re-check the
|
|
165
|
+
// same machine policy before a worker claims anything. Held jobs stay ready
|
|
166
|
+
// (nothing is deleted); removing the exclusion makes them eligible again.
|
|
167
|
+
// An explicit selection from Sessions uses event="manual" and intentionally
|
|
168
|
+
// overrides the automatic watcher policy.
|
|
169
|
+
const local = await readLocalSettings(dataDir);
|
|
103
170
|
const refusedIds = new Set();
|
|
171
|
+
const eligible = (job) => job.event === "manual" || automaticTranscriptIsEnabled(local, job.agent, job.sourcePath);
|
|
172
|
+
const sealIdleMs = Math.max(1, Math.trunc(options.sealIdleMs ?? DEFAULT_SEAL_IDLE_MS));
|
|
173
|
+
const cpuYield = options.backgroundCpuTarget === undefined
|
|
174
|
+
? undefined
|
|
175
|
+
: createBackgroundCpuYield({
|
|
176
|
+
target: options.backgroundCpuTarget,
|
|
177
|
+
signal: options.signal,
|
|
178
|
+
cpuUsage: options.cpuUsage,
|
|
179
|
+
now: options.now,
|
|
180
|
+
sleep: options.sleep,
|
|
181
|
+
});
|
|
104
182
|
let budget = max;
|
|
105
183
|
async function work(workerOwner) {
|
|
106
184
|
while (budget > 0) {
|
|
185
|
+
if (options.signal?.aborted)
|
|
186
|
+
return;
|
|
107
187
|
budget -= 1;
|
|
108
|
-
const job = await queue.claim(workerOwner, refusedIds);
|
|
188
|
+
const job = await queue.claim(workerOwner, refusedIds, eligible);
|
|
109
189
|
if (!job?.lease)
|
|
110
190
|
return;
|
|
111
191
|
const leaseId = job.lease.id;
|
|
192
|
+
const leaseKeeper = keepLeaseAlive(queue, job.id, leaseId, { signal: options.signal, idleMs: sealIdleMs });
|
|
193
|
+
// Queue metadata describes when intent was recorded; the live path may
|
|
194
|
+
// have grown or compacted since. Reserve and report the bounded state we
|
|
195
|
+
// can actually observe now, not yesterday's stale byte count.
|
|
196
|
+
const sourceSnapshot = await stat(job.sourcePath).then((entry) => ({
|
|
197
|
+
bytes: entry.size,
|
|
198
|
+
modifiedAt: entry.mtime.toISOString(),
|
|
199
|
+
modifiedAtMs: entry.mtimeMs,
|
|
200
|
+
})).catch(() => ({
|
|
201
|
+
bytes: job.sourceBytes,
|
|
202
|
+
modifiedAt: job.sourceModifiedAt,
|
|
203
|
+
modifiedAtMs: Date.parse(job.sourceModifiedAt),
|
|
204
|
+
}));
|
|
205
|
+
// Reclaim leaves a tiny native Codex rollout behind so the vendor's own
|
|
206
|
+
// session catalog can still resolve `codex resume <id>`. It already
|
|
207
|
+
// points at an encrypted archive; sealing that untouched pointer would
|
|
208
|
+
// create a second, content-free memory and make every safety scan churn.
|
|
209
|
+
// Recognition is deliberately exact. The moment Codex appends a turn,
|
|
210
|
+
// the file no longer has the three-line stub shape and is archived as
|
|
211
|
+
// genuinely new work on the normal path below.
|
|
212
|
+
const resumeStub = job.agent === "codex"
|
|
213
|
+
? await readUntouchedCodexResumeStub(job.sourcePath)
|
|
214
|
+
: null;
|
|
215
|
+
if (resumeStub) {
|
|
216
|
+
const archived = (await listArchives(dataDir)).find((record) => record.version === 2
|
|
217
|
+
&& record.id === resumeStub.archiveId && record.source.path === resolve(job.sourcePath)
|
|
218
|
+
&& record.reclaimed?.resumeStub?.version === 1
|
|
219
|
+
&& record.reclaimed.resumeStub.sessionId === resumeStub.sessionId
|
|
220
|
+
&& record.reclaimed.resumeStub.bytes === resumeStub.bytes
|
|
221
|
+
&& record.reclaimed.resumeStub.sha256 === resumeStub.sha256);
|
|
222
|
+
if (archived?.version === 2) {
|
|
223
|
+
await leaseKeeper.stop();
|
|
224
|
+
processed.push(await queue.complete(job.id, leaseId, {
|
|
225
|
+
archiveId: archived.id,
|
|
226
|
+
ciphertextSha256: archived.cipher.ciphertextSha256,
|
|
227
|
+
bytes: sourceSnapshot.bytes,
|
|
228
|
+
deduplicated: true,
|
|
229
|
+
}, { bytes: sourceSnapshot.bytes, modifiedAt: sourceSnapshot.modifiedAt }));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const completedSnapshot = async (record) => {
|
|
234
|
+
if (record.source.bytes !== sourceSnapshot.bytes)
|
|
235
|
+
return undefined;
|
|
236
|
+
const after = await stat(job.sourcePath).catch(() => null);
|
|
237
|
+
// If the agent appended or rewrote while sealing, a newer queue row is
|
|
238
|
+
// real work and must survive. Only settle siblings when the exact stat
|
|
239
|
+
// observation used for capacity stayed stable through completion.
|
|
240
|
+
if (!after?.isFile() || after.size !== sourceSnapshot.bytes || after.mtimeMs !== sourceSnapshot.modifiedAtMs)
|
|
241
|
+
return undefined;
|
|
242
|
+
return { bytes: sourceSnapshot.bytes, modifiedAt: sourceSnapshot.modifiedAt };
|
|
243
|
+
};
|
|
112
244
|
// Do not start an archive there is no room to finish. Sealing writes the
|
|
113
245
|
// sealed object while the original is still on disk, and the machines
|
|
114
246
|
// this runs on are, by definition, the ones short of space. The claim is
|
|
@@ -117,10 +249,10 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
117
249
|
// bytes as already spoken for.
|
|
118
250
|
const guarded = options.requireSpace !== false;
|
|
119
251
|
if (guarded)
|
|
120
|
-
reservations.reserve(job.id, spaceNeededFor(
|
|
252
|
+
reservations.reserve(job.id, spaceNeededFor(sourceSnapshot.bytes, 0));
|
|
121
253
|
try {
|
|
122
254
|
if (guarded) {
|
|
123
|
-
const room = await roomToArchive(dataDir,
|
|
255
|
+
const room = await roomToArchive(dataDir, sourceSnapshot.bytes, options.reserveBytes, reservations, job.id);
|
|
124
256
|
if (!room.ok) {
|
|
125
257
|
// Before leaving it queued, try the pressure valve: stream the
|
|
126
258
|
// seal straight into a chunk folder, whose peak local cost is one
|
|
@@ -129,12 +261,17 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
129
261
|
// size would starve every sibling seal in the pass.
|
|
130
262
|
if (guarded)
|
|
131
263
|
reservations.release(job.id);
|
|
132
|
-
const streamed = await streamUnderPressure(dataDir, job, phrase
|
|
264
|
+
const streamed = await leaseKeeper.wait(streamUnderPressure(dataDir, job, phrase, {
|
|
265
|
+
signal: leaseKeeper.signal,
|
|
266
|
+
onActivity: () => leaseKeeper.activity(),
|
|
267
|
+
...(cpuYield ? { onYield: async () => { leaseKeeper.activity(); await cpuYield(); } } : {}),
|
|
268
|
+
}));
|
|
133
269
|
if ("record" in streamed) {
|
|
270
|
+
await leaseKeeper.stop();
|
|
134
271
|
processed.push(await queue.complete(job.id, leaseId, {
|
|
135
272
|
archiveId: streamed.record.id, ciphertextSha256: streamed.record.cipher.ciphertextSha256,
|
|
136
273
|
bytes: streamed.record.source.bytes, deduplicated: streamed.deduplicated
|
|
137
|
-
}));
|
|
274
|
+
}, await completedSnapshot(streamed.record)));
|
|
138
275
|
continue;
|
|
139
276
|
}
|
|
140
277
|
// Left queued rather than failed: nothing is wrong with the
|
|
@@ -143,11 +280,22 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
143
280
|
// an earlier version stopped the whole loop here, and one 1.7 GB
|
|
144
281
|
// transcript at the head of a nearly-full disk's queue starved
|
|
145
282
|
// forty smaller jobs that would have fit.
|
|
283
|
+
await leaseKeeper.stop();
|
|
284
|
+
// Publish the refusal to sibling workers before the row becomes
|
|
285
|
+
// claimable again; otherwise another concurrent worker can take
|
|
286
|
+
// the same oversized row in the release/add scheduling gap.
|
|
287
|
+
refusedIds.add(job.id);
|
|
146
288
|
processed.push(await queue.release(job.id, leaseId, {
|
|
147
289
|
code: "insufficient_disk_space",
|
|
148
290
|
message: `${room.detail} Streaming it to cloud instead was not possible: ${streamed.reason}.`
|
|
149
291
|
}));
|
|
150
|
-
|
|
292
|
+
// `max` is a completion/attempt budget, not a limit on how many
|
|
293
|
+
// capacity-held rows we may inspect. Give this slot back so one
|
|
294
|
+
// oversized transcript cannot consume the daemon's single-item
|
|
295
|
+
// turn and starve a smaller session behind it. `refusedIds` keeps
|
|
296
|
+
// the pass finite: this row cannot be claimed again until a later
|
|
297
|
+
// drain, after disk or storage conditions may have changed.
|
|
298
|
+
budget += 1;
|
|
151
299
|
continue;
|
|
152
300
|
}
|
|
153
301
|
}
|
|
@@ -156,21 +304,65 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
156
304
|
// job at most every 16 MB or 5% — enough to move visibly on a 1.6 GB
|
|
157
305
|
// seal, cheap enough to be nothing on a 7 KB one. Per job, so several
|
|
158
306
|
// concurrent seals each report their own.
|
|
159
|
-
const totalBytes =
|
|
307
|
+
const totalBytes = sourceSnapshot.bytes;
|
|
160
308
|
let lastMark = 0;
|
|
161
309
|
const step = Math.max(16 * 1024 * 1024, Math.floor(totalBytes / 20));
|
|
162
310
|
const onProgress = totalBytes > 0 ? (bytesRead) => {
|
|
311
|
+
// Every actual source window resets the idle deadline. Queue-file
|
|
312
|
+
// progress remains throttled separately so liveness does not turn
|
|
313
|
+
// a large transcript into thousands of metadata writes.
|
|
314
|
+
leaseKeeper.activity();
|
|
163
315
|
if (bytesRead - lastMark < step && bytesRead < totalBytes)
|
|
164
316
|
return;
|
|
165
317
|
lastMark = bytesRead;
|
|
166
|
-
|
|
318
|
+
leaseKeeper.progress(bytesRead, totalBytes);
|
|
167
319
|
} : undefined;
|
|
168
|
-
|
|
169
|
-
|
|
320
|
+
// The project is knowable only while the source is still on disk —
|
|
321
|
+
// derive it NOW and store it in the record, the same answer
|
|
322
|
+
// `projects backfill` would compute later (and cannot, once the
|
|
323
|
+
// source is reclaimed).
|
|
324
|
+
const { projectIdentityOfRecord } = await import("./adapters.js");
|
|
325
|
+
const identity = await projectIdentityOfRecord({ source: { path: job.sourcePath, agent: job.agent }, createdAt: new Date().toISOString() })
|
|
326
|
+
.catch(() => ({ project: null, projectKey: null, projectPath: null }));
|
|
327
|
+
const { project, projectKey } = identity;
|
|
328
|
+
const { teamRecipientsForProject } = await import("./vault.js");
|
|
329
|
+
const teamRecipients = await teamRecipientsForProject(dataDir, project, projectKey);
|
|
330
|
+
// Normal unattended seals use the ranged zstd layout: better
|
|
331
|
+
// compression than gzip while preserving chunk-level search/restore.
|
|
332
|
+
// Pressure streaming stays on its own cloud chunk path and is not
|
|
333
|
+
// changed here because it has a separate bounded-memory protocol.
|
|
334
|
+
const archive = options.archive ?? archiveFile;
|
|
335
|
+
const cooperativeYield = cpuYield
|
|
336
|
+
? async () => { leaseKeeper.activity(); await cpuYield(); }
|
|
337
|
+
: undefined;
|
|
338
|
+
const record = await leaseKeeper.wait(archive(dataDir, job.sourcePath, phrase, job.agent, {
|
|
339
|
+
onProgress, onActivity: () => leaseKeeper.activity(), delta: options.delta !== false, project, projectKey, teamRecipients,
|
|
340
|
+
compression: "zstd-chunk", chunkBytes: LOCAL_SEAL_CHUNK_BYTES, indexInline: true,
|
|
341
|
+
signal: leaseKeeper.signal,
|
|
342
|
+
...(cooperativeYield ? { onYield: cooperativeYield } : {}),
|
|
343
|
+
}));
|
|
344
|
+
await leaseKeeper.stop();
|
|
345
|
+
processed.push(await queue.complete(job.id, leaseId, { archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.source.bytes, deduplicated: record.deduplicated }, await completedSnapshot(record)));
|
|
346
|
+
if (!record.deduplicated && record.version === 2 && record.source.project) {
|
|
347
|
+
sealedByProject.set(record.source.projectKey ?? `legacy:${record.source.project}`, {
|
|
348
|
+
project: record.source.project,
|
|
349
|
+
projectKey: record.source.projectKey ?? null,
|
|
350
|
+
sourcePath: job.sourcePath,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
170
353
|
}
|
|
171
354
|
catch (error) {
|
|
355
|
+
await leaseKeeper.stop().catch(() => undefined);
|
|
172
356
|
try {
|
|
173
|
-
|
|
357
|
+
// A service shutdown is not a failed preservation attempt. The
|
|
358
|
+
// partial is cancelled/removed by archiveFile and the exact row is
|
|
359
|
+
// made immediately claimable by the successor generation.
|
|
360
|
+
if (options.signal?.aborted) {
|
|
361
|
+
processed.push(await queue.release(job.id, leaseId, { code: "cancelled", message: "Sealkeep stopped before this seal completed; it will resume on the next pass." }));
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
processed.push(await queue.fail(job.id, leaseId, summarize(error)));
|
|
365
|
+
}
|
|
174
366
|
}
|
|
175
367
|
catch (leaseError) {
|
|
176
368
|
processed.push({ ...job, lastError: summarize(leaseError) });
|
|
@@ -178,6 +370,7 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
178
370
|
}
|
|
179
371
|
}
|
|
180
372
|
finally {
|
|
373
|
+
await leaseKeeper.stop().catch(() => undefined);
|
|
181
374
|
// Released whatever happened: a completed seal's bytes are now real and
|
|
182
375
|
// measured by statfs, a refused or failed one never spent them.
|
|
183
376
|
if (guarded)
|
|
@@ -186,5 +379,31 @@ export async function drainQueue(dataDir, phrase, options = {}) {
|
|
|
186
379
|
}
|
|
187
380
|
}
|
|
188
381
|
await Promise.all(Array.from({ length: concurrency }, (_, index) => work(concurrency > 1 ? `${owner}#${index + 1}` : owner)));
|
|
382
|
+
// ACTIVE-mode projects share at seal speed: the drain's fresh sessions
|
|
383
|
+
// become findable (one incremental index build + sync) and the team feed
|
|
384
|
+
// carries one `finished` line per project. Awaited, so "drain finished"
|
|
385
|
+
// means "announced" — and best-effort by design: a plane hiccup here must
|
|
386
|
+
// never fail seals that already completed.
|
|
387
|
+
if (sealedByProject.size > 0 && (options.announce !== false || options.onActiveSealed)) {
|
|
388
|
+
try {
|
|
389
|
+
const { readConfig, projectSharingOf } = await import("./vault.js");
|
|
390
|
+
const config = await readConfig(dataDir);
|
|
391
|
+
const active = [...sealedByProject.values()].filter(({ project, projectKey }) => projectSharingOf(config, project, projectKey) === "active");
|
|
392
|
+
options.onActiveSealed?.(active);
|
|
393
|
+
if (active.length > 0) {
|
|
394
|
+
if (options.announce !== false) {
|
|
395
|
+
const { buildContentIndex } = await import("./search.js");
|
|
396
|
+
await buildContentIndex(dataDir, phrase, { sync: true });
|
|
397
|
+
const { publishPresence, finishLineFrom } = await import("./presence.js");
|
|
398
|
+
for (const { project, projectKey, sourcePath } of active) {
|
|
399
|
+
await publishPresence(dataDir, { project, projectKey, kind: "finished", line: await finishLineFrom(sourcePath) }, phrase);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
process.emitWarning(`active-share skipped: ${error instanceof Error ? error.message : String(error)}`, "SealkeepActiveShare");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
189
408
|
return processed;
|
|
190
409
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sealkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/spala-ai/sealkeep.git"
|
|
9
|
+
},
|
|
6
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
11
|
"engines": {
|
|
8
|
-
"node": ">=22.
|
|
12
|
+
"node": ">=22.15.0"
|
|
9
13
|
},
|
|
10
14
|
"os": [
|
|
11
15
|
"darwin",
|
|
@@ -13,12 +17,12 @@
|
|
|
13
17
|
"win32"
|
|
14
18
|
],
|
|
15
19
|
"bin": {
|
|
16
|
-
"sealkeep": "dist/src/cli.js"
|
|
17
|
-
"vaultline": "dist/src/cli.js"
|
|
20
|
+
"sealkeep": "dist/src/cli.js"
|
|
18
21
|
},
|
|
19
22
|
"files": [
|
|
20
23
|
"LICENSE",
|
|
21
24
|
"dist",
|
|
25
|
+
"scripts/native-reboot-rehearsal.mjs",
|
|
22
26
|
"web",
|
|
23
27
|
"README.md",
|
|
24
28
|
"ARCHITECTURE.md",
|
|
@@ -32,28 +36,45 @@
|
|
|
32
36
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
33
37
|
"prepack": "npm run build",
|
|
34
38
|
"release:manifest": "node scripts/release.mjs",
|
|
39
|
+
"pretest": "node --import tsx test/global-setup.ts",
|
|
35
40
|
"test": "node --import tsx --test --test-concurrency=4 \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
|
|
36
|
-
"vaultline": "tsx src/cli.ts",
|
|
37
41
|
"mcp": "tsx src/mcp.ts",
|
|
38
42
|
"api": "tsx src/cli.ts api",
|
|
39
43
|
"control-plane": "tsx src/control-plane-cli.ts",
|
|
40
44
|
"dashboard": "tsx src/dashboard-cli.ts",
|
|
41
|
-
"build:
|
|
42
|
-
"
|
|
43
|
-
"
|
|
45
|
+
"build:visual": "npm --prefix sealkeep-visual-explanation-solid run build",
|
|
46
|
+
"build:site": "npm run build:visual && node tools/build-site.mjs",
|
|
47
|
+
"bundle:site": "npm run build:site && cd dist/site && zip -q -FS -r -X ../site.zip . && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
|
|
48
|
+
"acceptance": "tsx scripts/acceptance.ts",
|
|
44
49
|
"acceptance:managed": "node scripts/acceptance.mjs --managed",
|
|
45
50
|
"tokens": "node scripts/sync-tokens.mjs",
|
|
46
51
|
"browser:audit": "node scripts/browser/audit.mjs",
|
|
47
|
-
"browser:panel": "node scripts/browser/panel.mjs"
|
|
52
|
+
"browser:panel": "node scripts/browser/panel.mjs",
|
|
53
|
+
"rehearsal:company": "node scripts/company-rehearsal.mjs",
|
|
54
|
+
"rehearsal:automatic-memory": "node scripts/automatic-memory-rehearsal.mjs",
|
|
55
|
+
"rehearsal:memory-accuracy": "node --import tsx scripts/memory-accuracy-rehearsal.ts",
|
|
56
|
+
"rehearsal:team-realtime": "npm run build --silent && node --import tsx scripts/team-realtime-rehearsal.ts",
|
|
57
|
+
"rehearsal:hosted-team-realtime": "node --import tsx scripts/hosted-team-realtime-rehearsal.ts",
|
|
58
|
+
"rehearsal:install-upgrade": "node scripts/install-upgrade-rehearsal.mjs",
|
|
59
|
+
"rehearsal:vps-user-journey": "node --import tsx scripts/vps-user-journey-acceptance.ts",
|
|
60
|
+
"rehearsal:reboot-native": "node scripts/native-reboot-rehearsal.mjs",
|
|
61
|
+
"rehearsal:tiers": "node scripts/tier-matrix.mjs",
|
|
62
|
+
"verify": "npm run typecheck && npm test && npm run acceptance",
|
|
63
|
+
"mutation": "node scripts/mutation.mjs",
|
|
64
|
+
"verify:deep": "npm run verify && npm run mutation",
|
|
65
|
+
"soak:restore": "node scripts/soak-restore.mjs",
|
|
66
|
+
"test:ci": "node --import tsx --test --test-concurrency=2 \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\""
|
|
48
67
|
},
|
|
49
68
|
"devDependencies": {
|
|
50
69
|
"@types/node": "^22.10.2",
|
|
70
|
+
"socket.io": "^4.8.3",
|
|
51
71
|
"tsx": "^4.19.2",
|
|
52
|
-
"typescript": "^5.7.2"
|
|
53
|
-
"zod": "^4.3.6"
|
|
72
|
+
"typescript": "^5.7.2"
|
|
54
73
|
},
|
|
55
74
|
"dependencies": {
|
|
56
75
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
76
|
+
"socket.io-client": "^4.8.3",
|
|
77
|
+
"undici": "6.28.0",
|
|
57
78
|
"zod": "^4.4.3"
|
|
58
79
|
},
|
|
59
80
|
"keywords": [
|
|
@@ -67,4 +88,4 @@
|
|
|
67
88
|
"sessions"
|
|
68
89
|
],
|
|
69
90
|
"homepage": "https://sealkeep.spala.ai"
|
|
70
|
-
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Real native-keystore reboot receipt.
|
|
4
|
+
*
|
|
5
|
+
* PREPARE creates a disposable vault through the shipped CLI, requires the
|
|
6
|
+
* default backend to be native, and proves it can archive without a phrase
|
|
7
|
+
* flag. VERIFY refuses unless the operating-system boot identity changed,
|
|
8
|
+
* then archives again without any secret input. No phrase, token, or key is
|
|
9
|
+
* written to the receipt. CLEANUP removes the test phrase through the public
|
|
10
|
+
* CLI and deletes only the disposable rehearsal directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import { homedir, platform } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const run = promisify(execFile);
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const repo = join(here, "..");
|
|
24
|
+
const bin = process.env.SEALKEEP_REHEARSAL_BIN ?? join(repo, "dist", "src", "cli.js");
|
|
25
|
+
const node = process.execPath;
|
|
26
|
+
const action = process.argv[2] ?? "prepare";
|
|
27
|
+
const root = process.env.SEALKEEP_REBOOT_DIR ?? join(homedir(), ".sealkeep-reboot-rehearsal");
|
|
28
|
+
const dataDir = join(root, "vault");
|
|
29
|
+
const receiptPath = join(root, "receipt.json");
|
|
30
|
+
const expectedBackends = {
|
|
31
|
+
darwin: ["macos-keychain"],
|
|
32
|
+
linux: ["secret-service", "linux-systemd-creds"],
|
|
33
|
+
win32: ["windows-dpapi"],
|
|
34
|
+
}[platform()];
|
|
35
|
+
if (!expectedBackends) throw new Error(`No supported native backend for ${platform()}`);
|
|
36
|
+
|
|
37
|
+
const cleanEnv = { ...process.env };
|
|
38
|
+
delete cleanEnv.SEALKEEP_SECRET_BACKEND;
|
|
39
|
+
delete cleanEnv.VAULTLINE_SECRET_BACKEND;
|
|
40
|
+
|
|
41
|
+
async function bootId() {
|
|
42
|
+
if (platform() === "linux") return (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
|
|
43
|
+
if (platform() === "darwin") return (await run("sysctl", ["-n", "kern.boottime"])).stdout.trim();
|
|
44
|
+
return (await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')"])).stdout.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cli(args) {
|
|
48
|
+
const result = await run(node, [bin, ...args], { env: cleanEnv, maxBuffer: 4 * 1024 * 1024 });
|
|
49
|
+
return result.stdout.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function archiveCanary(name) {
|
|
53
|
+
const path = join(root, `${name}.jsonl`);
|
|
54
|
+
const marker = `native-reboot-${name}-${randomBytes(12).toString("hex")}`;
|
|
55
|
+
await writeFile(path, JSON.stringify({ type: "user", message: { content: marker } }) + "\n", { mode: 0o600 });
|
|
56
|
+
const output = JSON.parse(await cli(["archive", path, "--agent", "claude", "--data-dir", dataDir, "--json"]));
|
|
57
|
+
if (!output.id) throw new Error(`${name}: archive returned no id`);
|
|
58
|
+
return { id: output.id };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (action === "prepare") {
|
|
62
|
+
await rm(root, { recursive: true, force: true });
|
|
63
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
64
|
+
const setup = JSON.parse(await cli(["setup", "--data-dir", dataDir, "--json"]));
|
|
65
|
+
if (!expectedBackends.includes(setup.remembered?.backend)) {
|
|
66
|
+
await rm(root, { recursive: true, force: true });
|
|
67
|
+
throw new Error(`Native backend required: expected ${expectedBackends.join(" or ")}, got ${setup.remembered?.backend ?? "none"}`);
|
|
68
|
+
}
|
|
69
|
+
const backend = setup.remembered.backend;
|
|
70
|
+
const before = await archiveCanary("before");
|
|
71
|
+
await writeFile(receiptPath, JSON.stringify({
|
|
72
|
+
version: 1, platform: platform(), backend, bootId: await bootId(),
|
|
73
|
+
preparedAt: new Date().toISOString(), dataDir, beforeArchiveId: before.id,
|
|
74
|
+
}, null, 2) + "\n", { mode: 0o600 });
|
|
75
|
+
process.stdout.write(`PREPARED ${platform()} ${backend}; reboot this host, then run verify. No secret was written to the receipt.\n`);
|
|
76
|
+
} else if (action === "verify") {
|
|
77
|
+
const receipt = JSON.parse(await readFile(receiptPath, "utf8"));
|
|
78
|
+
const currentBoot = await bootId();
|
|
79
|
+
if (currentBoot === receipt.bootId) throw new Error("The OS boot identity did not change; this was not a real reboot");
|
|
80
|
+
const after = await archiveCanary("after");
|
|
81
|
+
await writeFile(receiptPath, JSON.stringify({ ...receipt, verifiedAt: new Date().toISOString(), bootIdAfter: currentBoot, afterArchiveId: after.id, passed: true }, null, 2) + "\n", { mode: 0o600 });
|
|
82
|
+
if (!expectedBackends.includes(receipt.backend)) throw new Error(`Receipt backend ${receipt.backend} is not native on ${platform()}`);
|
|
83
|
+
process.stdout.write(`PASS ${platform()} ${receipt.backend}: native secret survived a real reboot and sealed new work without re-entry.\n`);
|
|
84
|
+
} else if (action === "cleanup") {
|
|
85
|
+
await cli(["recovery", "forget-machine", "--confirm", "--data-dir", dataDir, "--json"]).catch(() => "");
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
process.stdout.write(`CLEANED ${root}; encrypted production vaults and integrations were not touched.\n`);
|
|
88
|
+
} else {
|
|
89
|
+
throw new Error("Usage: native-reboot-rehearsal.mjs prepare|verify|cleanup");
|
|
90
|
+
}
|