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/onboarding.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { detectAgents } from "./adapters.js";
|
|
2
|
+
import type { McpExec } from "./mcp-install.js";
|
|
2
3
|
export type SetupResult = {
|
|
3
4
|
initialized: boolean;
|
|
4
5
|
vaultId: string;
|
|
@@ -6,5 +7,11 @@ export type SetupResult = {
|
|
|
6
7
|
detectedAgents: Awaited<ReturnType<typeof detectAgents>>;
|
|
7
8
|
files: string[];
|
|
8
9
|
};
|
|
10
|
+
export type SetupVaultOptions = {
|
|
11
|
+
/** Agent detection and agent-owned config writes are confined to this home. */
|
|
12
|
+
home?: string;
|
|
13
|
+
/** Test seam: setup may invoke `which`/`where` and an agent's own installer. */
|
|
14
|
+
mcpExec?: McpExec;
|
|
15
|
+
};
|
|
9
16
|
/** Creates only Sealkeep-owned artifacts. Agent settings are never modified. */
|
|
10
|
-
export declare function setupVault(dataDir: string, providedPhrase?: string): Promise<SetupResult>;
|
|
17
|
+
export declare function setupVault(dataDir: string, providedPhrase?: string, options?: SetupVaultOptions): Promise<SetupResult>;
|
package/dist/src/onboarding.js
CHANGED
|
@@ -12,7 +12,7 @@ catch {
|
|
|
12
12
|
} }
|
|
13
13
|
async function writeJson(path, value) { await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); }
|
|
14
14
|
/** Creates only Sealkeep-owned artifacts. Agent settings are never modified. */
|
|
15
|
-
export async function setupVault(dataDir, providedPhrase) {
|
|
15
|
+
export async function setupVault(dataDir, providedPhrase, options = {}) {
|
|
16
16
|
const configPath = join(dataDir, "config.json");
|
|
17
17
|
let initialized = false;
|
|
18
18
|
let recoveryPhrase;
|
|
@@ -22,12 +22,12 @@ export async function setupVault(dataDir, providedPhrase) {
|
|
|
22
22
|
recoveryPhrase = created.phrase;
|
|
23
23
|
}
|
|
24
24
|
const status = await vaultStatus(dataDir);
|
|
25
|
-
const detectedAgents = await detectAgents();
|
|
25
|
+
const detectedAgents = await detectAgents(options.home);
|
|
26
26
|
const integrations = join(dataDir, "integrations");
|
|
27
27
|
const files = [];
|
|
28
28
|
for (const agent of ["codex", "claude"]) {
|
|
29
29
|
const path = join(integrations, `${agent}-hooks.json`);
|
|
30
|
-
await writeJson(path, hookConfig(agent, "
|
|
30
|
+
await writeJson(path, hookConfig(agent, "sealkeep", dataDir));
|
|
31
31
|
files.push(path);
|
|
32
32
|
}
|
|
33
33
|
// Written by `sealkeep mcp install`, which knows how to register with each
|
|
@@ -36,7 +36,7 @@ export async function setupVault(dataDir, providedPhrase) {
|
|
|
36
36
|
// that install path was built, so anyone who merged it got an agent that
|
|
37
37
|
// failed to start its server.
|
|
38
38
|
const { installMcpServer } = await import("./mcp-install.js");
|
|
39
|
-
const mcp = await installMcpServer(dataDir);
|
|
39
|
+
const mcp = await installMcpServer(dataDir, { home: options.home, exec: options.mcpExec });
|
|
40
40
|
for (const entry of mcp.agents)
|
|
41
41
|
if (entry.fragmentPath)
|
|
42
42
|
files.push(entry.fragmentPath);
|
package/dist/src/packages.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* migration is what makes the top tier viable, not a cost optimisation.
|
|
30
30
|
* See STORAGE.md.
|
|
31
31
|
*/
|
|
32
|
-
export type PackageId = "free" | "pro_lite" | "gb100" | "gb500" | "gb2000";
|
|
32
|
+
export type PackageId = "free" | "pro_lite" | "team" | "gb100" | "gb500" | "gb2000";
|
|
33
33
|
export type StoragePackage = {
|
|
34
34
|
id: PackageId;
|
|
35
35
|
name: string;
|
|
@@ -114,7 +114,7 @@ export declare function quotaMessage(state: QuotaState): string;
|
|
|
114
114
|
/**
|
|
115
115
|
* Rewrites a bare quota rejection into something a customer can act on.
|
|
116
116
|
*
|
|
117
|
-
* `call()` in cloud.ts maps a 402 straight to a
|
|
117
|
+
* `call()` in cloud.ts maps a 402 straight to a SealkeepError coded
|
|
118
118
|
* "forbidden" whose message is whatever the control plane said — historically
|
|
119
119
|
* just "Quota exceeded", with no numbers and no next step. This asks the same
|
|
120
120
|
* question `cloud status` would have answered in advance: where the account
|
package/dist/src/packages.js
CHANGED
|
@@ -9,13 +9,19 @@ export const PACKAGES = [
|
|
|
9
9
|
id: "pro_lite", name: "Pro Lite", gigabytes: 0, monthly: 2, managed: false,
|
|
10
10
|
blurb: "Your storage, our safety net: a sealed copy of your key in escrow, and every machine signed in. We still hold no bytes."
|
|
11
11
|
},
|
|
12
|
+
{
|
|
13
|
+
// Priced per seat rather than per terabyte: a company on its own bucket
|
|
14
|
+
// consumes none of our storage and should not buy a tier it never touches.
|
|
15
|
+
id: "team", name: "Team", gigabytes: 0, monthly: 9, managed: false,
|
|
16
|
+
blurb: "Per seat. Org recovery, project-scoped access, revocation, and leaked-key monitoring across every machine. Storage priced separately, or bring your own bucket."
|
|
17
|
+
},
|
|
12
18
|
{
|
|
13
19
|
id: "gb100", name: "100 GB", gigabytes: 100, monthly: 6, managed: true,
|
|
14
20
|
blurb: "Enough for years of transcripts on one machine. Nothing to create, nothing to configure."
|
|
15
21
|
},
|
|
16
22
|
{
|
|
17
23
|
id: "gb500", name: "500 GB", gigabytes: 500, monthly: 12, managed: true,
|
|
18
|
-
blurb: "
|
|
24
|
+
blurb: "Live team context, plus preserved history on every connected account."
|
|
19
25
|
},
|
|
20
26
|
{
|
|
21
27
|
id: "gb2000", name: "2 TB", gigabytes: 2000, monthly: 30, managed: true,
|
|
@@ -39,6 +45,8 @@ export function quotaBytes(id) {
|
|
|
39
45
|
* grandfathered grant should be reported honestly, not rounded into a tier.
|
|
40
46
|
*/
|
|
41
47
|
export function packageForAccount(account) {
|
|
48
|
+
if (account.plan === "team")
|
|
49
|
+
return findPackage("team") ?? PACKAGES[0];
|
|
42
50
|
if (account.plan === "pro_lite")
|
|
43
51
|
return findPackage("pro_lite") ?? PACKAGES[0];
|
|
44
52
|
if (account.plan !== "pro")
|
|
@@ -103,7 +111,7 @@ export function quotaMessage(state) {
|
|
|
103
111
|
/**
|
|
104
112
|
* Rewrites a bare quota rejection into something a customer can act on.
|
|
105
113
|
*
|
|
106
|
-
* `call()` in cloud.ts maps a 402 straight to a
|
|
114
|
+
* `call()` in cloud.ts maps a 402 straight to a SealkeepError coded
|
|
107
115
|
* "forbidden" whose message is whatever the control plane said — historically
|
|
108
116
|
* just "Quota exceeded", with no numbers and no next step. This asks the same
|
|
109
117
|
* question `cloud status` would have answered in advance: where the account
|
package/dist/src/passkey.d.ts
CHANGED
package/dist/src/passkey.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createPublicKey, hkdfSync, randomBytes } from "node:crypto";
|
|
2
2
|
import { fail } from "./errors.js";
|
|
3
|
-
import { x25519PrivateKeyFromRaw } from "../packages/
|
|
3
|
+
import { x25519PrivateKeyFromRaw } from "../packages/sealkeep-crypto/src/index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Touch ID as a second way into a vault, built on the WebAuthn PRF extension.
|
|
6
6
|
*
|
|
@@ -25,7 +25,7 @@ import { x25519PrivateKeyFromRaw } from "../packages/vaultline-crypto/src/index.
|
|
|
25
25
|
* still exists.
|
|
26
26
|
*/
|
|
27
27
|
/** Domain separation, so the same authenticator used elsewhere cannot collide with this. */
|
|
28
|
-
const INFO = Buffer.from("
|
|
28
|
+
const INFO = Buffer.from("sealkeep-passkey-recipient:v1");
|
|
29
29
|
export const PRF_SECRET_BYTES = 32;
|
|
30
30
|
/** A fresh per-vault salt, evaluated by the authenticator to produce the secret. */
|
|
31
31
|
export function generatePrfSalt() {
|
|
@@ -47,8 +47,3 @@ export function keysFromPrfSecret(secret) {
|
|
|
47
47
|
const publicKeyBase64 = createPublicKey(privateKey).export({ type: "spki", format: "der" }).subarray(-32).toString("base64");
|
|
48
48
|
return { publicKeyBase64, privateKeyRaw: material };
|
|
49
49
|
}
|
|
50
|
-
export function isPasskeyRecord(value) {
|
|
51
|
-
const record = value;
|
|
52
|
-
return !!record && typeof record.credentialId === "string" && typeof record.prfSalt === "string"
|
|
53
|
-
&& typeof record.label === "string" && typeof record.recipientId === "string";
|
|
54
|
-
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SealkeepError } from "./errors.js";
|
|
2
2
|
export declare const passwordLockPath: (dataDir: string) => string;
|
|
3
3
|
/**
|
|
4
4
|
* Structural damage, as opposed to a wrong password. Carries no hint about
|
|
5
5
|
* either secret, and its message is written for the window: what stopped
|
|
6
6
|
* working, what did not, and the way back in.
|
|
7
7
|
*/
|
|
8
|
-
export declare class PasswordLockCorruptError extends
|
|
8
|
+
export declare class PasswordLockCorruptError extends SealkeepError {
|
|
9
9
|
constructor(path: string, reason: string);
|
|
10
10
|
}
|
|
11
11
|
/** Seals the canonical phrase under a password, replacing any previous lock in one atomic step. */
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomBytes, scryptSync } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { aeadCipher, aeadDecipher } from "../packages/
|
|
5
|
-
import { TAG_BYTES } from "../packages/
|
|
6
|
-
import { fail,
|
|
4
|
+
import { aeadCipher, aeadDecipher } from "../packages/sealkeep-crypto/src/aead.js";
|
|
5
|
+
import { TAG_BYTES } from "../packages/sealkeep-crypto/src/format.js";
|
|
6
|
+
import { fail, SealkeepError } from "./errors.js";
|
|
7
7
|
/**
|
|
8
8
|
* A password in front of the phrase, never instead of it.
|
|
9
9
|
*
|
|
@@ -16,7 +16,7 @@ import { fail, VaultlineError } from "./errors.js";
|
|
|
16
16
|
* machine and is deliberately excluded from what a vault backup means.
|
|
17
17
|
*
|
|
18
18
|
* The scrypt cost matches every other phrase-derived key in this repo
|
|
19
|
-
* (crypto.ts,
|
|
19
|
+
* (crypto.ts, sealkeep-crypto/recipients.ts), so config.json's history does
|
|
20
20
|
* not repeat: the file guarding the phrase must never be cheaper to attack
|
|
21
21
|
* than the envelopes sitting next to it.
|
|
22
22
|
*/
|
|
@@ -26,14 +26,14 @@ const MAXMEM = 128 * 1024 * 1024;
|
|
|
26
26
|
const KEY_BYTES = 32;
|
|
27
27
|
const SALT_BYTES = 16;
|
|
28
28
|
const NONCE_BYTES = 12;
|
|
29
|
-
const AAD = Buffer.from("
|
|
29
|
+
const AAD = Buffer.from("sealkeep-phrase-lock:v1");
|
|
30
30
|
export const passwordLockPath = (dataDir) => join(dataDir, "phrase.lock");
|
|
31
31
|
/**
|
|
32
32
|
* Structural damage, as opposed to a wrong password. Carries no hint about
|
|
33
33
|
* either secret, and its message is written for the window: what stopped
|
|
34
34
|
* working, what did not, and the way back in.
|
|
35
35
|
*/
|
|
36
|
-
export class PasswordLockCorruptError extends
|
|
36
|
+
export class PasswordLockCorruptError extends SealkeepError {
|
|
37
37
|
constructor(path, reason) {
|
|
38
38
|
super("ciphertext_integrity_failed", "The saved password lock on this machine is damaged, so a password cannot unlock this vault. Your archives and your recovery phrase are unaffected — unlock with the phrase, then choose a password again.", { path, reason });
|
|
39
39
|
this.name = "PasswordLockCorruptError";
|
package/dist/src/paths.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const localApiTokenPath: (dataDir: string) => string;
|
|
2
|
+
/** Separate from the bearer: proves a recorded listener belongs to this vault without disclosing API authority. */
|
|
3
|
+
export declare const localUiProofKeyPath: (dataDir: string) => string;
|
|
2
4
|
/**
|
|
3
5
|
* Finds the bundled `web/` directory by walking up from this module.
|
|
4
6
|
*
|
package/dist/src/paths.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
export const localApiTokenPath = (dataDir) => join(dataDir, "local-api-token");
|
|
5
|
+
/** Separate from the bearer: proves a recorded listener belongs to this vault without disclosing API authority. */
|
|
6
|
+
export const localUiProofKeyPath = (dataDir) => join(dataDir, "local-ui-proof-key");
|
|
5
7
|
/**
|
|
6
8
|
* Finds the bundled `web/` directory by walking up from this module.
|
|
7
9
|
*
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence — the awareness tier git lacks and meetings badly imitate.
|
|
3
|
+
*
|
|
4
|
+
* Tiny sealed facts about WHO is doing WHAT right now: "Paul started:
|
|
5
|
+
* retry() in big-refactor", "Max finished: webhook double-fire — used the
|
|
6
|
+
* idempotency key". Bytes, not transcripts; published in seconds; readable by
|
|
7
|
+
* every member and by their agents (the sealkeep_team MCP tool), so the two
|
|
8
|
+
* expensive team questions — "is anyone already on this?" and "should I warn
|
|
9
|
+
* everyone I started?" — stop costing a human interruption.
|
|
10
|
+
*
|
|
11
|
+
* Facts, not proposals: presence never passes an approval gate. It rides the
|
|
12
|
+
* machinery the bridge lane proved — per-sender refs (collision-free), sealed
|
|
13
|
+
* to the vault phrase, relayed as ciphertext.
|
|
14
|
+
*/
|
|
15
|
+
import type { BackendName } from "./secrets.js";
|
|
16
|
+
export type PresenceKind = "started" | "finished" | "note";
|
|
17
|
+
export type PresenceEvent = {
|
|
18
|
+
project: string;
|
|
19
|
+
/** Stable local identity. The project label is never an access boundary. */
|
|
20
|
+
projectKey?: string;
|
|
21
|
+
kind: PresenceKind;
|
|
22
|
+
line: string;
|
|
23
|
+
from: string;
|
|
24
|
+
sender: string;
|
|
25
|
+
seq: number;
|
|
26
|
+
at: string;
|
|
27
|
+
/** Authenticated by the hosted append endpoint for cross-account events. */
|
|
28
|
+
authorAccountId?: number;
|
|
29
|
+
/** Hosted append cursor. Present on team events; authoritative over skewed client clocks. */
|
|
30
|
+
serverId?: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* A hosted space is the project identity shared by the team; `event.project`
|
|
34
|
+
* is only the sender's local folder label. Two developers are explicitly
|
|
35
|
+
* allowed to map `checkout-api` and `api-local` to the same space, so an event
|
|
36
|
+
* read from that authenticated space belongs to both aliases. Normalise it at
|
|
37
|
+
* the receiving boundary instead of dropping valid teammate work because the
|
|
38
|
+
* two machines named their folders differently.
|
|
39
|
+
*/
|
|
40
|
+
export declare function localiseTeamPresence(events: readonly PresenceEvent[], localProject: string, localProjectKey?: string | null): PresenceEvent[];
|
|
41
|
+
export declare function presenceRef(sender: string, seq: number): string;
|
|
42
|
+
export declare function presenceRefOf(vaultRef: string): {
|
|
43
|
+
sender: string;
|
|
44
|
+
seq: number;
|
|
45
|
+
} | null;
|
|
46
|
+
export declare function publishPresence(dataDir: string, input: {
|
|
47
|
+
project: string;
|
|
48
|
+
projectKey?: string | null;
|
|
49
|
+
kind: PresenceKind;
|
|
50
|
+
line: string;
|
|
51
|
+
from?: string;
|
|
52
|
+
}, phrase: string, preferred?: BackendName): Promise<{
|
|
53
|
+
ref: string;
|
|
54
|
+
event: PresenceEvent;
|
|
55
|
+
}>;
|
|
56
|
+
/** Open one presence blob back into its event; the sealed meta is the truth. */
|
|
57
|
+
export declare function openPresenceBlob(blob: Buffer, phrase: string): PresenceEvent;
|
|
58
|
+
/**
|
|
59
|
+
* The team feed: newest presence events, optionally one project's. Presence
|
|
60
|
+
* objects are tiny, so opening a bounded number of them costs less than one
|
|
61
|
+
* transcript page — and the cap keeps a years-old account honest.
|
|
62
|
+
*/
|
|
63
|
+
export declare function listPresence(dataDir: string, phrase: string, options?: {
|
|
64
|
+
project?: string;
|
|
65
|
+
projectKey?: string;
|
|
66
|
+
limit?: number;
|
|
67
|
+
fetchCap?: number;
|
|
68
|
+
preferred?: BackendName;
|
|
69
|
+
}): Promise<PresenceEvent[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Who is on something RIGHT NOW: the newest state event per (person, project)
|
|
72
|
+
* is a `started` no `finished` closed, and it is fresh. Progress notes enrich
|
|
73
|
+
* the feed but never close an open session. This is the collision
|
|
74
|
+
* check — the question an agent asks before taking overlapping work.
|
|
75
|
+
*/
|
|
76
|
+
export declare function activeNow(events: PresenceEvent[], options?: {
|
|
77
|
+
staleAfterMs?: number;
|
|
78
|
+
now?: number;
|
|
79
|
+
}): PresenceEvent[];
|
|
80
|
+
/**
|
|
81
|
+
* A cheap honest one-liner from a transcript tail — for auto-published
|
|
82
|
+
* `finished`. Codex rollouts get their last agent_message (the raw-text
|
|
83
|
+
* scrape used to surface tool noise like "Script completed Wall time 0.1
|
|
84
|
+
* seconds"); everything else falls back to the last quoted text.
|
|
85
|
+
*/
|
|
86
|
+
export declare function finishLineFrom(sessionPath: string): Promise<string>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence — the awareness tier git lacks and meetings badly imitate.
|
|
3
|
+
*
|
|
4
|
+
* Tiny sealed facts about WHO is doing WHAT right now: "Paul started:
|
|
5
|
+
* retry() in big-refactor", "Max finished: webhook double-fire — used the
|
|
6
|
+
* idempotency key". Bytes, not transcripts; published in seconds; readable by
|
|
7
|
+
* every member and by their agents (the sealkeep_team MCP tool), so the two
|
|
8
|
+
* expensive team questions — "is anyone already on this?" and "should I warn
|
|
9
|
+
* everyone I started?" — stop costing a human interruption.
|
|
10
|
+
*
|
|
11
|
+
* Facts, not proposals: presence never passes an approval gate. It rides the
|
|
12
|
+
* machinery the bridge lane proved — per-sender refs (collision-free), sealed
|
|
13
|
+
* to the vault phrase, relayed as ciphertext.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { encryptArchive, decryptArchive } from "../packages/sealkeep-crypto/src/envelope.js";
|
|
19
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
20
|
+
import { fail } from "./errors.js";
|
|
21
|
+
import { requestUpload, putCiphertext, completeUpload, pullCiphertext, frameObject, unframeObject, listCloudArchives } from "./cloud.js";
|
|
22
|
+
import { bridgeSender, withSenderLock } from "./bridge.js";
|
|
23
|
+
import { readConfig, teamSpaceOf } from "./vault.js";
|
|
24
|
+
const sha256hex = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
|
25
|
+
const SENDER_RE = /^[a-f0-9]{8}$/;
|
|
26
|
+
/**
|
|
27
|
+
* A hosted space is the project identity shared by the team; `event.project`
|
|
28
|
+
* is only the sender's local folder label. Two developers are explicitly
|
|
29
|
+
* allowed to map `checkout-api` and `api-local` to the same space, so an event
|
|
30
|
+
* read from that authenticated space belongs to both aliases. Normalise it at
|
|
31
|
+
* the receiving boundary instead of dropping valid teammate work because the
|
|
32
|
+
* two machines named their folders differently.
|
|
33
|
+
*/
|
|
34
|
+
export function localiseTeamPresence(events, localProject, localProjectKey) {
|
|
35
|
+
return events.map((event) => {
|
|
36
|
+
if (localProjectKey) {
|
|
37
|
+
return event.project === localProject && event.projectKey === localProjectKey
|
|
38
|
+
? event
|
|
39
|
+
: { ...event, project: localProject, projectKey: localProjectKey };
|
|
40
|
+
}
|
|
41
|
+
if (event.project === localProject && event.projectKey === undefined)
|
|
42
|
+
return event;
|
|
43
|
+
// Membership in the authenticated team space supplies the identity when a
|
|
44
|
+
// legacy caller has no stable key. Do not retain a contributor's unrelated
|
|
45
|
+
// machine-local key as though it identified the receiving checkout.
|
|
46
|
+
const { projectKey: _ignored, ...rest } = event;
|
|
47
|
+
return { ...rest, project: localProject };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function presenceRef(sender, seq) {
|
|
51
|
+
if (!SENDER_RE.test(sender))
|
|
52
|
+
fail("invalid_argument", `A presence sender id must be 8 hex chars; got "${sender}"`);
|
|
53
|
+
return `presence-${sender}-${String(seq).padStart(9, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
export function presenceRefOf(vaultRef) {
|
|
56
|
+
const match = /^presence-([a-f0-9]{8})-([0-9]{9})$/.exec(vaultRef);
|
|
57
|
+
return match ? { sender: match[1], seq: Number(match[2]) } : null;
|
|
58
|
+
}
|
|
59
|
+
/** One line, prompt-safe and small: presence is a glance, not a report. */
|
|
60
|
+
// eslint-disable-next-line no-control-regex
|
|
61
|
+
const clean = (text) => text.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 140);
|
|
62
|
+
export async function publishPresence(dataDir, input, phrase, preferred) {
|
|
63
|
+
if (!input.project.trim())
|
|
64
|
+
fail("invalid_argument", "Presence needs a project");
|
|
65
|
+
const { state, seq } = await withSenderLock(dataDir, async () => {
|
|
66
|
+
const state = await bridgeSender(dataDir);
|
|
67
|
+
const seq = state.outSeq["presence"] ?? 0;
|
|
68
|
+
state.outSeq["presence"] = seq + 1;
|
|
69
|
+
await writeFile(join(dataDir, "bridge", "sender.json"), JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
70
|
+
return { state, seq };
|
|
71
|
+
});
|
|
72
|
+
const event = {
|
|
73
|
+
project: input.project, kind: input.kind, line: clean(input.line),
|
|
74
|
+
from: input.from ?? state.label, sender: state.id, seq, at: new Date().toISOString(),
|
|
75
|
+
...(input.projectKey ? { projectKey: input.projectKey } : {}),
|
|
76
|
+
};
|
|
77
|
+
const binding = teamSpaceOf(await readConfig(dataDir), input.project, input.projectKey);
|
|
78
|
+
if (binding) {
|
|
79
|
+
const { publishTeamPresenceEvent } = await import("./team-presence.js");
|
|
80
|
+
await publishTeamPresenceEvent(dataDir, binding.spaceKey, event, phrase, preferred);
|
|
81
|
+
return { ref: presenceRef(state.id, seq), event };
|
|
82
|
+
}
|
|
83
|
+
const { envelope, ciphertext } = encryptArchive(Buffer.from(event.line, "utf8"), {
|
|
84
|
+
recipients: [{ kind: "phrase", phrase: canonicalPhrase(phrase) }],
|
|
85
|
+
adapter: { agent: "presence", version: JSON.stringify({ ...event, line: undefined }) },
|
|
86
|
+
});
|
|
87
|
+
const blob = frameObject(envelope, ciphertext);
|
|
88
|
+
const ref = presenceRef(state.id, seq);
|
|
89
|
+
const lease = await requestUpload(dataDir, { vaultRef: ref, ciphertextSha256: sha256hex(blob), bytes: blob.length }, preferred);
|
|
90
|
+
await putCiphertext(lease, blob);
|
|
91
|
+
await completeUpload(dataDir, lease.lease_id, blob.length, preferred);
|
|
92
|
+
return { ref, event };
|
|
93
|
+
}
|
|
94
|
+
/** Open one presence blob back into its event; the sealed meta is the truth. */
|
|
95
|
+
export function openPresenceBlob(blob, phrase) {
|
|
96
|
+
const { envelope, ciphertext } = unframeObject(blob);
|
|
97
|
+
if (!envelope)
|
|
98
|
+
fail("internal", "A presence object is not framed");
|
|
99
|
+
const line = decryptArchive(envelope, ciphertext, { phrase: canonicalPhrase(phrase) }).toString("utf8");
|
|
100
|
+
const metaRaw = envelope.manifest?.adapter?.version;
|
|
101
|
+
if (!metaRaw)
|
|
102
|
+
fail("internal", "A presence object carries no meta");
|
|
103
|
+
return { ...JSON.parse(metaRaw), line };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The team feed: newest presence events, optionally one project's. Presence
|
|
107
|
+
* objects are tiny, so opening a bounded number of them costs less than one
|
|
108
|
+
* transcript page — and the cap keeps a years-old account honest.
|
|
109
|
+
*/
|
|
110
|
+
export async function listPresence(dataDir, phrase, options = {}) {
|
|
111
|
+
const limit = options.limit ?? 30;
|
|
112
|
+
if (options.project) {
|
|
113
|
+
const binding = teamSpaceOf(await readConfig(dataDir), options.project, options.projectKey);
|
|
114
|
+
if (binding) {
|
|
115
|
+
const { readTeamPresence } = await import("./team-presence.js");
|
|
116
|
+
const page = await readTeamPresence(dataDir, binding.spaceKey, phrase, { preferred: options.preferred });
|
|
117
|
+
// Membership and decryption already scoped this page to exactly one
|
|
118
|
+
// hosted project. `event.project` is the sender's local alias, not an
|
|
119
|
+
// access-control boundary, so filtering it here loses legitimate work
|
|
120
|
+
// from teammates who use a different checkout folder name.
|
|
121
|
+
return localiseTeamPresence(page.events, options.project, options.projectKey).slice(0, limit);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const fetchCap = options.fetchCap ?? 120;
|
|
125
|
+
const remote = await listCloudArchives(dataDir, options.preferred).catch(() => []);
|
|
126
|
+
const refs = remote.map((row) => row.vault_ref).filter((ref) => presenceRefOf(ref) !== null);
|
|
127
|
+
// Per-sender streams are seq-ordered by the ref string; take each sender's
|
|
128
|
+
// newest first, so the cap cannot starve a quiet teammate.
|
|
129
|
+
const senders = new Set(refs.map((ref) => presenceRefOf(ref).sender));
|
|
130
|
+
const perSender = Math.max(8, Math.ceil(fetchCap / Math.max(1, senders.size)));
|
|
131
|
+
const bySender = new Map();
|
|
132
|
+
for (const ref of [...refs].sort().reverse()) {
|
|
133
|
+
const parsed = presenceRefOf(ref);
|
|
134
|
+
const list = bySender.get(parsed.sender) ?? [];
|
|
135
|
+
if (list.length < perSender)
|
|
136
|
+
list.push(ref);
|
|
137
|
+
bySender.set(parsed.sender, list);
|
|
138
|
+
}
|
|
139
|
+
const picked = [...bySender.values()].flat().slice(0, fetchCap);
|
|
140
|
+
const events = [];
|
|
141
|
+
// Every blob is independent. Pulling 120 one by one turned "who is working"
|
|
142
|
+
// into a minute-long startup on real teams; a small bounded pool makes the
|
|
143
|
+
// feed one handful of network round trips without opening 120 sockets at
|
|
144
|
+
// once. Push by slot, then sort below, so completion order cannot leak into
|
|
145
|
+
// the answer.
|
|
146
|
+
let next = 0;
|
|
147
|
+
await Promise.all(Array.from({ length: Math.min(12, picked.length) }, async () => {
|
|
148
|
+
while (next < picked.length) {
|
|
149
|
+
const ref = picked[next++];
|
|
150
|
+
try {
|
|
151
|
+
const { ciphertext: blob } = await pullCiphertext(dataDir, ref, options.preferred);
|
|
152
|
+
const event = openPresenceBlob(blob, phrase);
|
|
153
|
+
if (!options.project
|
|
154
|
+
|| (options.projectKey ? event.projectKey === options.projectKey : event.project === options.project))
|
|
155
|
+
events.push(event);
|
|
156
|
+
}
|
|
157
|
+
catch { /* an unreadable one is skipped, never fatal to the feed */ }
|
|
158
|
+
}
|
|
159
|
+
}));
|
|
160
|
+
events.sort((a, b) => (a.at < b.at ? 1 : -1));
|
|
161
|
+
return events.slice(0, limit);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Who is on something RIGHT NOW: the newest state event per (person, project)
|
|
165
|
+
* is a `started` no `finished` closed, and it is fresh. Progress notes enrich
|
|
166
|
+
* the feed but never close an open session. This is the collision
|
|
167
|
+
* check — the question an agent asks before taking overlapping work.
|
|
168
|
+
*/
|
|
169
|
+
export function activeNow(events, options = {}) {
|
|
170
|
+
const staleAfter = options.staleAfterMs ?? 8 * 3600_000;
|
|
171
|
+
const now = options.now ?? Date.now();
|
|
172
|
+
const newestPer = new Map();
|
|
173
|
+
for (const event of events) {
|
|
174
|
+
if (event.kind === "note")
|
|
175
|
+
continue;
|
|
176
|
+
// A team member controls the encrypted sender label, but not the account
|
|
177
|
+
// id attached by the authenticated append endpoint. Keep one active state
|
|
178
|
+
// per real account even if a malicious member copies another sender id.
|
|
179
|
+
const author = event.authorAccountId === undefined ? event.sender : `account:${event.authorAccountId}`;
|
|
180
|
+
const key = `${author}|${event.projectKey ?? event.project}`;
|
|
181
|
+
const held = newestPer.get(key);
|
|
182
|
+
const newerByServer = event.serverId !== undefined && held?.serverId !== undefined && event.serverId > held.serverId;
|
|
183
|
+
const newerByClock = (event.serverId === undefined || held?.serverId === undefined) && (!held || event.at > held.at);
|
|
184
|
+
if (!held || newerByServer || newerByClock)
|
|
185
|
+
newestPer.set(key, event);
|
|
186
|
+
}
|
|
187
|
+
return [...newestPer.values()]
|
|
188
|
+
.filter((event) => event.kind === "started" && now - Date.parse(event.at) < staleAfter)
|
|
189
|
+
.sort((a, b) => a.serverId !== undefined && b.serverId !== undefined ? b.serverId - a.serverId : (a.at < b.at ? 1 : -1));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* A cheap honest one-liner from a transcript tail — for auto-published
|
|
193
|
+
* `finished`. Codex rollouts get their last agent_message (the raw-text
|
|
194
|
+
* scrape used to surface tool noise like "Script completed Wall time 0.1
|
|
195
|
+
* seconds"); everything else falls back to the last quoted text.
|
|
196
|
+
*/
|
|
197
|
+
export async function finishLineFrom(sessionPath) {
|
|
198
|
+
try {
|
|
199
|
+
const raw = await readFile(sessionPath);
|
|
200
|
+
const tail = raw.subarray(Math.max(0, raw.length - 32768)).toString("utf8");
|
|
201
|
+
const lines = tail.split("\n");
|
|
202
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(lines[i]);
|
|
205
|
+
// Codex has used three rollout shapes for an assistant line:
|
|
206
|
+
// - event_msg.payload.type = agent_message (older builds)
|
|
207
|
+
// - event_msg item_completed → item.type = AgentMessage (0.150)
|
|
208
|
+
// - response_item → payload.role = assistant (the durable message)
|
|
209
|
+
// Read semantic fields only. A raw `"text"` scrape mistakes tool
|
|
210
|
+
// output such as "Script completed" for the handoff — exactly what a
|
|
211
|
+
// live Mac→VPS rehearsal caught.
|
|
212
|
+
let message = parsed.payload?.type === "agent_message" ? parsed.payload.message : undefined;
|
|
213
|
+
if (!message && parsed.payload?.type === "item_completed" && parsed.payload.item?.type === "AgentMessage") {
|
|
214
|
+
message = parsed.payload.item.content?.find((part) => part.type === "Text" && part.text?.trim())?.text;
|
|
215
|
+
}
|
|
216
|
+
if (!message && parsed.type === "response_item" && parsed.payload?.type === "message" && parsed.payload.role === "assistant") {
|
|
217
|
+
message = parsed.payload.content?.find((part) => (part.type === "output_text" || part.type === "text") && part.text?.trim())?.text;
|
|
218
|
+
}
|
|
219
|
+
// Claude Code: `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text",…}]}}`.
|
|
220
|
+
// Without this the scrape below took the LAST string in the file, which
|
|
221
|
+
// in a Claude transcript is usually the harness's own system-reminder —
|
|
222
|
+
// so a preserved session's one line read "<system-reminder> <total_tokens>…".
|
|
223
|
+
if (!message && parsed.type === "assistant" && parsed.message?.role === "assistant") {
|
|
224
|
+
message = parsed.message.content?.find((part) => part.type === "text" && part.text?.trim())?.text;
|
|
225
|
+
}
|
|
226
|
+
if (message && message.trim())
|
|
227
|
+
return clean(message);
|
|
228
|
+
}
|
|
229
|
+
catch { /* a torn first line of the tail window — keep looking */ }
|
|
230
|
+
}
|
|
231
|
+
const texts = [...tail.matchAll(/"(?:text|content)":"((?:[^"\\]|\\.){8,160})"/g)].map((m) => m[1])
|
|
232
|
+
// Harness scaffolding is never what a session was about.
|
|
233
|
+
.filter((text) => !/^\s*(?:\\n)*\s*<(?:system-reminder|local-command-stdout|task-notification|command-name|command-message)/.test(text));
|
|
234
|
+
const last = texts[texts.length - 1];
|
|
235
|
+
return last ? clean(last.replace(/\\n/g, " ").replace(/\\"/g, '"')) : "session sealed";
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return "session sealed";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A referenced, resettable idle deadline for work that owns durable state.
|
|
3
|
+
*
|
|
4
|
+
* `progress()` is the only way to extend the budget. The timer never renews a
|
|
5
|
+
* queue lease by itself: silence therefore cannot make a dead seal look alive.
|
|
6
|
+
* `wait()` also races uncooperative injected/provider promises, while the
|
|
7
|
+
* derived signal lets real implementations stop their underlying I/O.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createProgressDeadline(options: {
|
|
10
|
+
operation: string;
|
|
11
|
+
idleMs: number;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
}): {
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
progress(): void;
|
|
16
|
+
wait<T>(work: Promise<T>): Promise<T>;
|
|
17
|
+
close(): void;
|
|
18
|
+
};
|
|
19
|
+
/** One bounded remote operation. Completion is progress; a caller may create
|
|
20
|
+
* another budget for the next meta/blob/publication step. */
|
|
21
|
+
export declare function withProgressDeadline<T>(operation: string, idleMs: number, signal: AbortSignal | undefined, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SealkeepError } from "./errors.js";
|
|
2
|
+
function abortReason(signal, fallback) {
|
|
3
|
+
if (signal.reason instanceof Error)
|
|
4
|
+
return signal.reason;
|
|
5
|
+
const error = new Error(fallback);
|
|
6
|
+
error.name = "AbortError";
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A referenced, resettable idle deadline for work that owns durable state.
|
|
11
|
+
*
|
|
12
|
+
* `progress()` is the only way to extend the budget. The timer never renews a
|
|
13
|
+
* queue lease by itself: silence therefore cannot make a dead seal look alive.
|
|
14
|
+
* `wait()` also races uncooperative injected/provider promises, while the
|
|
15
|
+
* derived signal lets real implementations stop their underlying I/O.
|
|
16
|
+
*/
|
|
17
|
+
export function createProgressDeadline(options) {
|
|
18
|
+
if (!Number.isFinite(options.idleMs) || options.idleMs <= 0) {
|
|
19
|
+
throw new Error(`${options.operation} idle timeout must be positive`);
|
|
20
|
+
}
|
|
21
|
+
const idleMs = Math.max(1, Math.trunc(options.idleMs));
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
let timer;
|
|
24
|
+
let closed = false;
|
|
25
|
+
const timeout = () => new SealkeepError("operation_timeout", `${options.operation} made no progress for ${Math.max(1, Math.round(idleMs / 1_000))} seconds and was stopped. It can be retried safely.`, { operation: options.operation, idleMs, retryable: true });
|
|
26
|
+
const arm = () => {
|
|
27
|
+
if (closed || controller.signal.aborted)
|
|
28
|
+
return;
|
|
29
|
+
if (timer)
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
// Deliberately referenced. A process must not disappear while it owns a
|
|
32
|
+
// queue lease or an index publication merely because the provider promise
|
|
33
|
+
// is the only other pending work.
|
|
34
|
+
timer = setTimeout(() => controller.abort(timeout()), idleMs);
|
|
35
|
+
};
|
|
36
|
+
const abortFromParent = () => {
|
|
37
|
+
if (!controller.signal.aborted) {
|
|
38
|
+
controller.abort(abortReason(options.signal, `${options.operation} was cancelled`));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
if (options.signal?.aborted)
|
|
42
|
+
abortFromParent();
|
|
43
|
+
else
|
|
44
|
+
options.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
45
|
+
arm();
|
|
46
|
+
return {
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
progress: arm,
|
|
49
|
+
async wait(work) {
|
|
50
|
+
if (controller.signal.aborted) {
|
|
51
|
+
// The caller may have started work before cancellation. Observe its
|
|
52
|
+
// eventual rejection even though cancellation wins immediately.
|
|
53
|
+
void work.catch(() => undefined);
|
|
54
|
+
throw abortReason(controller.signal, `${options.operation} was cancelled`);
|
|
55
|
+
}
|
|
56
|
+
let onAbort;
|
|
57
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
58
|
+
onAbort = () => reject(abortReason(controller.signal, `${options.operation} was cancelled`));
|
|
59
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([work, aborted]);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (onAbort)
|
|
66
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
close() {
|
|
70
|
+
if (closed)
|
|
71
|
+
return;
|
|
72
|
+
closed = true;
|
|
73
|
+
if (timer)
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
timer = undefined;
|
|
76
|
+
options.signal?.removeEventListener("abort", abortFromParent);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** One bounded remote operation. Completion is progress; a caller may create
|
|
81
|
+
* another budget for the next meta/blob/publication step. */
|
|
82
|
+
export async function withProgressDeadline(operation, idleMs, signal, work) {
|
|
83
|
+
const deadline = createProgressDeadline({ operation, idleMs, signal });
|
|
84
|
+
try {
|
|
85
|
+
deadline.signal.throwIfAborted();
|
|
86
|
+
return await deadline.wait(work(deadline.signal));
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
deadline.close();
|
|
90
|
+
}
|
|
91
|
+
}
|