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/restore.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { lstat, mkdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
|
-
import { fail } from "./errors.js";
|
|
5
|
+
import { fail, isSealkeepError } from "./errors.js";
|
|
6
6
|
import { recordAudit } from "./audit.js";
|
|
7
7
|
import { assertPhraseOpens, findArchive, readConfig, restoreRecordToFile } from "./vault.js";
|
|
8
|
+
import { hashFileRange } from "../packages/sealkeep-crypto/src/index.js";
|
|
9
|
+
import { readSourceBytes, resolveSourceReference } from "./source-reader.js";
|
|
10
|
+
import { LEGACY_BUFFER_LIMIT_BYTES } from "./byte-stream.js";
|
|
11
|
+
import { parkExistingPathNoClobber, publishTempCreateIfAbsent, restoreParkedCreateIfAbsent, validateNoFollowPath, } from "./reclaim-transaction.js";
|
|
8
12
|
/**
|
|
9
13
|
* Where each adapter's transcripts legitimately live. Restoring "natively" means
|
|
10
14
|
* putting bytes back at the recorded original path, and only if that path is still
|
|
@@ -21,9 +25,24 @@ function within(candidate, root) {
|
|
|
21
25
|
}
|
|
22
26
|
/** Resolves the adapter-native destination, refusing anything outside the agent's roots. */
|
|
23
27
|
export function nativeRestoreTarget(record, home = homedir()) {
|
|
24
|
-
|
|
28
|
+
// A record's agent tag is sticky: archive a Codex session once without
|
|
29
|
+
// naming the agent and it is "custom" forever, because re-archiving the same
|
|
30
|
+
// bytes returns the original record rather than writing a new one. The
|
|
31
|
+
// recorded path still says plainly where the file came from, so when the tag
|
|
32
|
+
// is unhelpful, ask the path. This only ever RESOLVES to a real agent root —
|
|
33
|
+
// the containment check below still refuses anything outside one.
|
|
34
|
+
let agent = record.source.agent;
|
|
25
35
|
if (agent !== "codex" && agent !== "claude") {
|
|
26
|
-
|
|
36
|
+
const resolved = resolve(record.source.path);
|
|
37
|
+
for (const candidate of ["codex", "claude"]) {
|
|
38
|
+
if (NATIVE_ROOTS[candidate](home).some((root) => within(resolved, root))) {
|
|
39
|
+
agent = candidate;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (agent !== "codex" && agent !== "claude") {
|
|
45
|
+
fail("native_restore_unavailable", `No native restore path is defined for agent "${record.source.agent}", and ${record.source.path} is not inside a known agent directory; pass an explicit destination`, { agent: record.source.agent });
|
|
27
46
|
}
|
|
28
47
|
const roots = NATIVE_ROOTS[agent](home);
|
|
29
48
|
const original = resolve(record.source.path);
|
|
@@ -32,56 +51,273 @@ export function nativeRestoreTarget(record, home = homedir()) {
|
|
|
32
51
|
}
|
|
33
52
|
return original;
|
|
34
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Build and verify a native transcript parent without following a symlinked
|
|
56
|
+
* `.codex`, `.claude`, sessions root, or date/project directory. The lexical
|
|
57
|
+
* target still remains the adapter's recorded path; canonicalisation here
|
|
58
|
+
* only makes platform aliases such as macOS `/var` -> `/private/var` safe to
|
|
59
|
+
* compare.
|
|
60
|
+
*/
|
|
61
|
+
export async function validateNativeRestoreOutput(output, home = homedir(), leaf = "missing-or-file") {
|
|
62
|
+
const lexicalHome = resolve(home);
|
|
63
|
+
const canonicalHome = await realpath(lexicalHome);
|
|
64
|
+
const suffix = output === lexicalHome ? "" : output.startsWith(lexicalHome.endsWith(sep) ? lexicalHome : `${lexicalHome}${sep}`)
|
|
65
|
+
? output.slice(lexicalHome.length + (lexicalHome.endsWith(sep) ? 0 : 1))
|
|
66
|
+
: null;
|
|
67
|
+
if (suffix === null || suffix === "")
|
|
68
|
+
fail("path_not_permitted", `Native transcript path is outside the selected home: ${output}`);
|
|
69
|
+
const canonicalOutput = join(canonicalHome, suffix);
|
|
70
|
+
const roots = [...NATIVE_ROOTS.codex(canonicalHome), ...NATIVE_ROOTS.claude(canonicalHome)];
|
|
71
|
+
const parent = dirname(canonicalOutput);
|
|
72
|
+
const root = roots.find((candidate) => within(parent, candidate));
|
|
73
|
+
if (!root)
|
|
74
|
+
fail("path_not_permitted", `Native transcript path is outside the agent roots: ${output}`);
|
|
75
|
+
let cursor = canonicalHome;
|
|
76
|
+
for (const part of parent.slice(canonicalHome.length).split(sep).filter(Boolean)) {
|
|
77
|
+
cursor = join(cursor, part);
|
|
78
|
+
await mkdir(cursor, { mode: 0o700 }).catch((error) => {
|
|
79
|
+
if (error.code !== "EEXIST")
|
|
80
|
+
throw error;
|
|
81
|
+
});
|
|
82
|
+
const info = await lstat(cursor);
|
|
83
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(cursor) !== cursor) {
|
|
84
|
+
fail("path_not_permitted", `Native transcript parent is not a canonical directory: ${cursor}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await validateNoFollowPath(canonicalOutput, roots, { leaf });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
fail("path_not_permitted", `Native transcript path is not a regular file under its agent root: ${output}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function errorCode(error) {
|
|
95
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
96
|
+
? String(error.code)
|
|
97
|
+
: undefined;
|
|
98
|
+
}
|
|
99
|
+
function destinationConflict(output) {
|
|
100
|
+
fail("destination_exists", `${output} already exists or changed while recovery was finishing. Its bytes were kept. Re-run after the writer stops, or choose another destination.`, { destination: output });
|
|
101
|
+
}
|
|
102
|
+
async function expectedFileOrNull(path) {
|
|
103
|
+
try {
|
|
104
|
+
return await hashFileRange(path);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (errorCode(error) === "ENOENT")
|
|
108
|
+
return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function publishRestoreNoClobber(temp, output, expected) {
|
|
113
|
+
try {
|
|
114
|
+
await publishTempCreateIfAbsent(temp, output, expected);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (errorCode(error) === "EEXIST")
|
|
118
|
+
destinationConflict(output);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function commitRestore(temp, output, expected, policy, operations) {
|
|
123
|
+
await operations?.beforeCommit?.({ output, policy });
|
|
124
|
+
if (policy === "refuse") {
|
|
125
|
+
await publishRestoreNoClobber(temp, output, expected);
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
const displaced = await expectedFileOrNull(output);
|
|
129
|
+
if (!displaced) {
|
|
130
|
+
await publishRestoreNoClobber(temp, output, expected);
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
// `replace` changes the active path, but it cannot safely unlink the old
|
|
134
|
+
// inode: another process may still have it open and append after any final
|
|
135
|
+
// stat/hash check. Keep that inode at a visible safety path, just as backup
|
|
136
|
+
// does, so every concurrent byte remains addressable.
|
|
137
|
+
const tag = policy === "backup" ? "backup" : "replaced";
|
|
138
|
+
const parkPath = `${output}.sealkeep-${tag}-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`;
|
|
139
|
+
await operations?.beforePark?.({ output, parkPath, policy });
|
|
140
|
+
try {
|
|
141
|
+
await parkExistingPathNoClobber(output, parkPath, displaced);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (errorCode(error) === "EEXIST") {
|
|
145
|
+
fail("destination_exists", `Recovery could not reserve ${parkPath}. Both that file and the current destination were kept; retry creates a different safety path.`, { destination: output, preservedPath: parkPath });
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
await operations?.afterPark?.({ output, parkPath, policy });
|
|
150
|
+
try {
|
|
151
|
+
await publishRestoreNoClobber(temp, output, expected);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// If nobody claimed the destination, put the displaced inode back. If a
|
|
155
|
+
// contender did, both it and the parked inode remain visible and untouched.
|
|
156
|
+
const rollback = await restoreParkedCreateIfAbsent(parkPath, output, displaced)
|
|
157
|
+
.then(() => "restored", () => "preserved");
|
|
158
|
+
if (rollback === "preserved") {
|
|
159
|
+
fail("destination_exists", `Recovery could not safely publish at ${output}. Any competing file was kept there, and the displaced file was kept at ${parkPath}.`, { destination: output, preservedPath: parkPath });
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return { backupPath: parkPath };
|
|
164
|
+
}
|
|
35
165
|
/**
|
|
36
166
|
* Restores one archive atomically.
|
|
37
167
|
*
|
|
38
|
-
* The plaintext is streamed to a temporary file beside the destination and
|
|
39
|
-
*
|
|
168
|
+
* The plaintext is streamed to a temporary file beside the destination and
|
|
169
|
+
* published with create-if-absent semantics, so a crash never leaves a
|
|
170
|
+
* half-written transcript — and a transcript
|
|
40
171
|
* of any size is restored without ever holding it in memory. An existing file
|
|
41
|
-
* is refused by default
|
|
42
|
-
* `replace`
|
|
172
|
+
* is refused by default. `backup` preserves the displaced inode beside the
|
|
173
|
+
* restored copy. `replace` replaces the active destination too, but retains
|
|
174
|
+
* the displaced inode under a visible `.sealkeep-replaced-*` safety path: no
|
|
175
|
+
* portable filesystem primitive can prove that another process has stopped
|
|
176
|
+
* writing through an already-open descriptor.
|
|
43
177
|
*/
|
|
44
178
|
export async function restoreArchive(dataDir, id, phrase, options = {}) {
|
|
45
179
|
// A recipient key opens an archive it was wrapped for, without the phrase.
|
|
46
180
|
// That is how an organisation reads what someone who has left sealed, and
|
|
47
181
|
// how a project member reads a project they were added to.
|
|
48
182
|
const key = options.privateKey;
|
|
49
|
-
const record = await findArchive(dataDir, id);
|
|
50
183
|
const native = options.native === true;
|
|
51
184
|
if (native && options.destination)
|
|
52
185
|
fail("invalid_argument", "Choose either --native or an explicit destination, not both");
|
|
53
186
|
if (!native && !options.destination)
|
|
54
187
|
fail("invalid_argument", "A destination is required unless --native is used");
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
fail("
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (!key)
|
|
68
|
-
assertPhraseOpens(await readConfig(dataDir), record, phrase);
|
|
69
|
-
await mkdir(dirname(output), { recursive: true });
|
|
70
|
-
const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
|
|
71
|
-
let backupPath;
|
|
72
|
-
let bytes;
|
|
188
|
+
const source = options.source ?? (options.project ? await resolveSourceReference(dataDir, phrase, id, options.project, options.projectKey) : undefined);
|
|
189
|
+
if (options.source && id !== options.source.archiveId)
|
|
190
|
+
fail("invalid_argument", "archiveId must match the immutable source reference");
|
|
191
|
+
if (source?.spaceKey && native)
|
|
192
|
+
fail("native_restore_unavailable", "A Team source must be restored to an explicit destination on this device");
|
|
193
|
+
if (source?.spaceKey && options.destination && (options.overwrite ?? "refuse") === "refuse"
|
|
194
|
+
&& await lstat(resolve(options.destination)).then(() => true, () => false))
|
|
195
|
+
destinationConflict(resolve(options.destination));
|
|
196
|
+
const teamRead = source ? await readSourceBytes(dataDir, phrase, source, {
|
|
197
|
+
maxWireBytes: LEGACY_BUFFER_LIMIT_BYTES, maxDecodedBytes: LEGACY_BUFFER_LIMIT_BYTES,
|
|
198
|
+
signal: AbortSignal.timeout(120_000),
|
|
199
|
+
}) : undefined;
|
|
73
200
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
201
|
+
const record = teamRead?.record ?? await findArchive(dataDir, id);
|
|
202
|
+
const output = native ? nativeRestoreTarget(record, options.home ?? homedir()) : resolve(options.destination);
|
|
203
|
+
const policy = options.overwrite ?? "refuse";
|
|
204
|
+
const existing = await lstat(output).then(() => true).catch(() => false);
|
|
205
|
+
// A dry run answers "what would this restore do" and stops before anything
|
|
206
|
+
// is created: no directory, no temp file, no audit row. The phrase (or the
|
|
207
|
+
// team read above) is still proven, so a wrong phrase fails here too instead
|
|
208
|
+
// of appearing to preview successfully. An existing destination is reported,
|
|
209
|
+
// not thrown — the caller asked what would happen, and this is the answer.
|
|
210
|
+
if (options.dryRun) {
|
|
211
|
+
if (!key && !teamRead)
|
|
212
|
+
assertPhraseOpens(await readConfig(dataDir), record, phrase);
|
|
213
|
+
return { record, output, bytes: record.source.bytes, native, dryRun: true, destinationExists: existing };
|
|
214
|
+
}
|
|
215
|
+
if (existing && policy === "refuse") {
|
|
216
|
+
fail("destination_exists", `${output} already exists. Re-run with --overwrite backup or replace; either keeps the displaced file at a visible safety path.`, { destination: output });
|
|
217
|
+
}
|
|
218
|
+
// Proved before anything is created, because the restore itself writes as it
|
|
219
|
+
// decrypts: a wrong phrase must not leave a directory or a partial file
|
|
220
|
+
// behind. The config decides what "proved" means — unwrapping for a sealed
|
|
221
|
+
// vault, the identity check for a plain one.
|
|
222
|
+
// The phrase check proves the PHRASE opens this vault; a key holder has no
|
|
223
|
+
// phrase and the wrap itself is the proof, so the unwrap below is the gate.
|
|
224
|
+
if (!key && !teamRead)
|
|
225
|
+
assertPhraseOpens(await readConfig(dataDir), record, phrase);
|
|
226
|
+
if (native)
|
|
227
|
+
await validateNativeRestoreOutput(output, options.home ?? homedir(), "missing-or-file");
|
|
228
|
+
else
|
|
229
|
+
await mkdir(dirname(output), { recursive: true });
|
|
230
|
+
const temp = join(dirname(output), `.${basename(output)}.${randomUUID()}.partial`);
|
|
231
|
+
let backupPath;
|
|
232
|
+
let bytes;
|
|
233
|
+
try {
|
|
234
|
+
if (teamRead) {
|
|
235
|
+
await writeFile(temp, teamRead.bytes, { mode: 0o600, flag: "wx" });
|
|
236
|
+
bytes = teamRead.bytes.length;
|
|
237
|
+
await teamRead.assertCurrent();
|
|
238
|
+
}
|
|
239
|
+
else
|
|
240
|
+
({ bytes } = await restoreRecordToFile(dataDir, record, key ? { privateKey: key } : phrase, temp));
|
|
241
|
+
const committed = await commitRestore(temp, output, { bytes, sha256: record.source.sha256 }, policy, teamRead ? { ...options.operations, beforeCommit: async (input) => {
|
|
242
|
+
await options.operations?.beforeCommit?.(input);
|
|
243
|
+
await teamRead.assertCurrent();
|
|
244
|
+
} } : options.operations);
|
|
245
|
+
backupPath = committed.backupPath;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
await rm(temp, { force: true });
|
|
249
|
+
// Refusals are what an audit log is FOR: a revoked colleague probing an
|
|
250
|
+
// archive after removal used to leave no trace at all.
|
|
251
|
+
await recordAudit(dataDir, "archive.restore", "denied", { archiveId: id, output, reason: isSealkeepError(error) ? error.code : "error" }).catch(() => undefined);
|
|
252
|
+
throw error;
|
|
78
253
|
}
|
|
79
|
-
|
|
254
|
+
finally {
|
|
255
|
+
teamRead?.bytes.fill(0);
|
|
256
|
+
}
|
|
257
|
+
await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: id, output, bytes, native, backedUp: Boolean(backupPath) });
|
|
258
|
+
return { record, output, bytes, native, backupPath };
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
teamRead?.bytes.fill(0);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Proves an archive can produce the bytes it claims, before anything is taken.
|
|
266
|
+
*
|
|
267
|
+
* Decrypts the archive to a scratch file and hashes the result. Nothing else
|
|
268
|
+
* in this file is allowed to trash a source until this has returned true —
|
|
269
|
+
* the point is to catch a codec or storage fault while the original is still
|
|
270
|
+
* on disk, which is the only moment catching it is worth anything.
|
|
271
|
+
*/
|
|
272
|
+
export async function verifyArchiveReadsBack(dataDir, archiveId, phrase, expectedSha256) {
|
|
273
|
+
const { createHash } = await import("node:crypto");
|
|
274
|
+
const { Writable } = await import("node:stream");
|
|
275
|
+
const { findArchive, restoreRecordToFile } = await import("./vault.js");
|
|
276
|
+
/**
|
|
277
|
+
* Hash the plaintext as it goes past; write it nowhere.
|
|
278
|
+
*
|
|
279
|
+
* This used to restore to a scratch file and hash that, which cost as much
|
|
280
|
+
* FREE DISK as the original session — so on a full disk the proof was
|
|
281
|
+
* impossible exactly when it was needed, and `squeeze` reported the ENOSPC as
|
|
282
|
+
* "the archive did not read back", which reads as corruption. The bytes are
|
|
283
|
+
* still hashed and still compared; they simply never land.
|
|
284
|
+
*/
|
|
285
|
+
const digest = createHash("sha256");
|
|
286
|
+
const sink = new Writable({ write(chunk, _enc, done) { digest.update(chunk); done(); } });
|
|
287
|
+
try {
|
|
288
|
+
const record = await findArchive(dataDir, archiveId);
|
|
289
|
+
await restoreRecordToFile(dataDir, record, phrase, sink);
|
|
290
|
+
return digest.digest("hex") === expectedSha256;
|
|
80
291
|
}
|
|
81
292
|
catch (error) {
|
|
82
|
-
|
|
83
|
-
|
|
293
|
+
// A delta chain or a plain-mode vault rebuilds through real files. Those
|
|
294
|
+
// fall back to the scratch-file proof, which is correct where there is room.
|
|
295
|
+
const { isSealkeepError } = await import("./errors.js");
|
|
296
|
+
if (isSealkeepError(error) && error.code === "invalid_argument")
|
|
297
|
+
return verifyViaScratchFile(dataDir, archiveId, phrase, expectedSha256);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** The original proof: restore to a scratch file and hash it. Needs room for the whole session. */
|
|
302
|
+
async function verifyViaScratchFile(dataDir, archiveId, phrase, expectedSha256) {
|
|
303
|
+
const { mkdtemp, rm: removeFile } = await import("node:fs/promises");
|
|
304
|
+
const { tmpdir } = await import("node:os");
|
|
305
|
+
const { join: joinPath } = await import("node:path");
|
|
306
|
+
const { createReadStream } = await import("node:fs");
|
|
307
|
+
const { createHash } = await import("node:crypto");
|
|
308
|
+
const { pipeline } = await import("node:stream/promises");
|
|
309
|
+
const scratch = await mkdtemp(joinPath(tmpdir(), "sealkeep-verify-"));
|
|
310
|
+
const probe = joinPath(scratch, "readback");
|
|
311
|
+
try {
|
|
312
|
+
await restoreArchive(dataDir, archiveId, phrase, { destination: probe, overwrite: "replace" });
|
|
313
|
+
const digest = createHash("sha256");
|
|
314
|
+
await pipeline(createReadStream(probe), digest);
|
|
315
|
+
return digest.digest("hex") === expectedSha256;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await removeFile(scratch, { recursive: true, force: true });
|
|
84
322
|
}
|
|
85
|
-
await recordAudit(dataDir, "archive.restore", "allowed", { archiveId: id, output, bytes, native, backedUp: Boolean(backupPath) });
|
|
86
|
-
return { record, output, bytes, native, backupPath };
|
|
87
323
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable, model-visible marker. It deliberately lives in an ordinary Codex
|
|
3
|
+
* message rather than in a made-up rollout record type: old and new Codex
|
|
4
|
+
* builds may ignore fields they do not know, but the conversation loader has
|
|
5
|
+
* always understood user/assistant messages.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CODEX_RESUME_STUB_MARKER = "sealkeep://codex-resume-stub/v1";
|
|
8
|
+
export type CodexResumeStub = {
|
|
9
|
+
version: 1;
|
|
10
|
+
archiveId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
path: string;
|
|
13
|
+
bytes: number;
|
|
14
|
+
sha256: string;
|
|
15
|
+
};
|
|
16
|
+
export type PreparedCodexResumeStub = CodexResumeStub & {
|
|
17
|
+
tempPath: string;
|
|
18
|
+
atime: Date;
|
|
19
|
+
mtime: Date;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Only real, non-symlinked native Codex rollout locations participate.
|
|
23
|
+
*
|
|
24
|
+
* Lexical containment is insufficient here: a symlinked `sessions` directory
|
|
25
|
+
* can make a path spell `~/.codex/sessions/...` while naming an unrelated file
|
|
26
|
+
* elsewhere. Check every user-controlled path component with lstat, then check
|
|
27
|
+
* canonical containment as a second independent condition.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isNativeCodexSessionPath(path: string, home?: string): Promise<boolean>;
|
|
30
|
+
/** Canonical no-follow validation shared by reclaim and crash reconciliation. */
|
|
31
|
+
export declare function validateNativeCodexSessionPath(path: string, home?: string, leaf?: "file" | "missing-or-file"): Promise<string>;
|
|
32
|
+
/** The native filename, not a parent directory or a longer hex token, owns the session id. */
|
|
33
|
+
export declare function codexSessionIdMatchesFilename(path: string, sessionId: string): boolean;
|
|
34
|
+
/** Validates that replacing this exact native rollout with a pointer is supported. */
|
|
35
|
+
export declare function assertCodexResumePointerSupported(path: string, home?: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Re-validates a staged original against its logical native filename.
|
|
38
|
+
* Crash reconciliation uses this after upgrades: staging lives inside the
|
|
39
|
+
* vault, but the rollout UUID contract still belongs to the original path.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assertCodexResumePointerMetadataSupported(contentsPath: string, logicalNativePath: string): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* A reclaim journal may outlive the binary that wrote it. Do not let such a
|
|
44
|
+
* journal turn an arbitrary neighbouring file into a replacement temp: only
|
|
45
|
+
* the private basename generated by prepareCodexResumeStub is eligible.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertCodexResumePointerTempPath(tempPath: string): void;
|
|
48
|
+
/**
|
|
49
|
+
* Return true only when an existing pathname is the exact journaled pointer
|
|
50
|
+
* and its structured contents bind it to this native session and archive.
|
|
51
|
+
* A missing or fingerprint-mismatched pathname returns false because generic
|
|
52
|
+
* reconciliation already retains it. A matching-but-invalid pathname throws:
|
|
53
|
+
* deleting or publishing it based only on a journal-supplied hash would let a
|
|
54
|
+
* corrupt journal target an unrelated neighbouring file.
|
|
55
|
+
*/
|
|
56
|
+
export declare function validateCodexResumePointerBinding(contentsPath: string, logicalNativePath: string, archiveId: string, expected: {
|
|
57
|
+
bytes: number;
|
|
58
|
+
sha256: string;
|
|
59
|
+
}): Promise<boolean>;
|
|
60
|
+
/** Own and validated parent IDs that may identify a live Codex process. */
|
|
61
|
+
export declare function codexSessionProcessIds(path: string): Promise<string[]>;
|
|
62
|
+
/** IDs plus project cwd used when a live Codex process has no UUID in argv. */
|
|
63
|
+
export declare function codexSessionProcessIdentity(path: string): Promise<{
|
|
64
|
+
ids: string[];
|
|
65
|
+
cwd: string;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* Builds the replacement beside the source, while the original is still
|
|
69
|
+
* present and fully verified. The caller transaction-stages the original first
|
|
70
|
+
* and then publishes this prepared file into its native path without
|
|
71
|
+
* overwriting a path the agent recreated in the meantime.
|
|
72
|
+
*
|
|
73
|
+
* The original session-meta line is kept byte-for-byte. It is the format
|
|
74
|
+
* contract of the installed Codex build and carries the stable id, cwd, git
|
|
75
|
+
* project, source kind and original timestamp. Even a large modern metadata
|
|
76
|
+
* line is tiny beside a multi-GB rollout and avoids guessing at vendor fields.
|
|
77
|
+
*/
|
|
78
|
+
export declare function prepareCodexResumeStub(sourcePath: string, archiveId: string, options?: {
|
|
79
|
+
home?: string;
|
|
80
|
+
project?: string | null;
|
|
81
|
+
/** Test seam; production fsyncs the complete pointer inode after utimes. */
|
|
82
|
+
syncPreparedFile?: (path: string) => Promise<void>;
|
|
83
|
+
}): Promise<PreparedCodexResumeStub | null>;
|
|
84
|
+
/** Installs an already-prepared pointer without clobbering a recreated path. */
|
|
85
|
+
export declare function installCodexResumeStub(stub: PreparedCodexResumeStub): Promise<void>;
|
|
86
|
+
export declare function discardCodexResumeStub(stub: PreparedCodexResumeStub | null): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Recognises only the complete, untouched pointer format. Once Codex appends
|
|
89
|
+
* a new turn there are more than the three expected lines, so the worker
|
|
90
|
+
* archives that new work normally instead of permanently excluding the file.
|
|
91
|
+
*/
|
|
92
|
+
export declare function readUntouchedCodexResumeStub(path: string): Promise<CodexResumeStub | null>;
|