talon-agent 5.14.0 → 5.18.2
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/LICENSE +202 -21
- package/LICENSE-MIT +21 -0
- package/NOTICE +16 -0
- package/README.md +14 -7
- package/package.json +6 -4
- package/prompts/system/heartbeat-agent.md +1 -1
- package/src/backend/agy/factory.ts +3 -0
- package/src/backend/agy/mcp/config.ts +14 -2
- package/src/backend/claude-sdk/factory.ts +3 -0
- package/src/backend/claude-sdk/options.ts +24 -3
- package/src/backend/codex/factory.ts +3 -0
- package/src/backend/codex/init.ts +4 -0
- package/src/backend/codex/mcp-config.ts +10 -0
- package/src/backend/codex/oauth-incompat.ts +1 -1
- package/src/backend/codex/token-usage.ts +2 -2
- package/src/backend/openai-agents/factory.ts +3 -0
- package/src/backend/openai-agents/mcp-pool.ts +4 -0
- package/src/backend/remote-server/factory.ts +3 -0
- package/src/backend/remote-server/mcp.ts +3 -0
- package/src/backend/runtime/prompt/prompt-format.ts +3 -3
- package/src/bootstrap.ts +8 -0
- package/src/cli/commands/backup.ts +61 -5
- package/src/cli/commands/mesh.ts +133 -0
- package/src/cli/config.ts +3 -1
- package/src/cli/daemon-api.ts +22 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/install-sources.ts +40 -5
- package/src/cli/plugin.ts +10 -0
- package/src/cli/setup.ts +45 -4
- package/src/cli/skill.ts +3 -0
- package/src/core/agent-runtime/backend-registry.ts +16 -0
- package/src/core/backup/archive/crypt.ts +429 -0
- package/src/core/backup/archive/manifest-auth.ts +98 -0
- package/src/core/backup/passphrase.ts +130 -0
- package/src/core/backup/plan.ts +66 -13
- package/src/core/backup/restore-guard.ts +101 -0
- package/src/core/backup/restore.ts +249 -32
- package/src/core/backup/snapshot.ts +418 -60
- package/src/core/backup/sources/plugins.ts +223 -0
- package/src/core/backup/sources/relocate.ts +136 -0
- package/src/core/backup/sources/sessions.ts +198 -0
- package/src/core/backup/store.ts +3 -1
- package/src/core/backup/types.ts +66 -0
- package/src/core/backup/upload.ts +66 -6
- package/src/core/config/index.ts +140 -8
- package/src/core/daemon/control.ts +9 -0
- package/src/core/daemon/discovery.ts +7 -0
- package/src/core/engine/backend-router/headroom.ts +29 -5
- package/src/core/engine/backend-router/usage.ts +6 -2
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
- package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
- package/src/core/engine/gateway-actions/index.ts +4 -2
- package/src/core/engine/gateway-actions/native/index.ts +24 -0
- package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
- package/src/core/engine/gateway-auth.ts +164 -0
- package/src/core/engine/gateway-routes.ts +100 -5
- package/src/core/engine/gateway.ts +12 -4
- package/src/core/mcp-hub/guest-scope.ts +170 -29
- package/src/core/mcp-hub/index.ts +33 -16
- package/src/core/mcp-hub/talon-server.ts +71 -14
- package/src/core/mesh/credentials/admin.ts +146 -0
- package/src/core/mesh/credentials/index.ts +19 -0
- package/src/core/mesh/credentials/store.ts +443 -0
- package/src/core/mesh/credentials/token.ts +45 -0
- package/src/core/mesh/credentials/types.ts +83 -0
- package/src/core/mesh/devices/service.ts +58 -6
- package/src/core/mesh/links/bridge-links.ts +46 -4
- package/src/core/mesh/links/node-binaries.ts +1 -1
- package/src/core/mesh/links/node-provision.ts +8 -1
- package/src/core/models/active-model.ts +1 -1
- package/src/core/plugin/loader.ts +4 -0
- package/src/core/plugin/mcp.ts +4 -0
- package/src/core/tools/bridge.ts +2 -1
- package/src/core/types.ts +13 -0
- package/src/core/weaver/weaver.ts +47 -15
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
- package/src/frontend/discord/handlers/delivery.ts +3 -0
- package/src/frontend/discord/handlers/queue.ts +1 -0
- package/src/frontend/native/bridge/auth-guard.ts +277 -0
- package/src/frontend/native/bridge/auth.ts +84 -1
- package/src/frontend/native/bridge/credentials/claims.ts +51 -0
- package/src/frontend/native/bridge/credentials/principal.ts +200 -0
- package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
- package/src/frontend/native/bridge/routes/auth.ts +37 -0
- package/src/frontend/native/bridge/routes/chats.ts +20 -2
- package/src/frontend/native/bridge/routes/host.ts +11 -1
- package/src/frontend/native/bridge/routes/index.ts +2 -0
- package/src/frontend/native/bridge/routes/mesh.ts +72 -15
- package/src/frontend/native/bridge/routes/table.ts +73 -51
- package/src/frontend/native/bridge/server.ts +266 -83
- package/src/frontend/native/index.ts +54 -3
- package/src/frontend/native/turn/turn.ts +2 -0
- package/src/frontend/teams/turn.ts +1 -0
- package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
- package/src/frontend/telegram/actions/send.ts +4 -0
- package/src/frontend/telegram/admin.ts +20 -0
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/commands/state.ts +7 -9
- package/src/frontend/telegram/handlers/access.ts +31 -10
- package/src/frontend/telegram/handlers/delivery.ts +14 -1
- package/src/frontend/telegram/handlers/group-access.ts +50 -0
- package/src/frontend/telegram/handlers/messages.ts +1 -0
- package/src/frontend/telegram/handlers/queue.ts +14 -0
- package/src/frontend/telegram/handlers/state.ts +2 -2
- package/src/frontend/telegram/index.ts +32 -9
- package/src/frontend/telegram/middleware.ts +2 -2
- package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
- package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
- package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
- package/src/frontend/telegram/userbot.ts +103 -10
- package/src/frontend/terminal/index.ts +8 -1
- package/src/frontend/whatsapp/commands.ts +3 -3
- package/src/frontend/whatsapp/messages/inbound.ts +1 -0
- package/src/plugins/playwright/index.ts +1 -1
- package/src/storage/backup/index.ts +1 -1
- package/src/storage/db.ts +23 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The snapshot builder — one run, one directory,
|
|
2
|
+
* The snapshot builder — one run, one directory, up to three parts
|
|
3
|
+
* (state, login sessions, palace).
|
|
3
4
|
*
|
|
4
5
|
* Everything here streams: entries are handed to the tar writer one at a
|
|
5
6
|
* time, the tar bytes go straight through zstd into the part file, and
|
|
@@ -20,14 +21,24 @@
|
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import { createWriteStream } from "node:fs";
|
|
23
|
-
import {
|
|
24
|
-
|
|
24
|
+
import {
|
|
25
|
+
mkdir,
|
|
26
|
+
readFile,
|
|
27
|
+
readdir,
|
|
28
|
+
rm,
|
|
29
|
+
stat,
|
|
30
|
+
writeFile,
|
|
31
|
+
} from "node:fs/promises";
|
|
32
|
+
import { homedir, hostname } from "node:os";
|
|
25
33
|
import { dirname, join, resolve } from "node:path";
|
|
26
34
|
import { pipeline } from "node:stream/promises";
|
|
27
35
|
import { dirs } from "../../util/paths.js";
|
|
28
36
|
import { log, logWarn } from "../../util/log.js";
|
|
29
37
|
import { talonVersion } from "../../util/version.js";
|
|
30
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
snapshotDatabase,
|
|
40
|
+
snapshotSqliteFile,
|
|
41
|
+
} from "../../storage/backup/index.js";
|
|
31
42
|
import { TalonError } from "../errors.js";
|
|
32
43
|
import {
|
|
33
44
|
Sha256Tap,
|
|
@@ -35,18 +46,31 @@ import {
|
|
|
35
46
|
treeHash,
|
|
36
47
|
type TreeFile,
|
|
37
48
|
} from "./archive/digest.js";
|
|
49
|
+
import {
|
|
50
|
+
ENCRYPTED_SUFFIX,
|
|
51
|
+
createEncryptor,
|
|
52
|
+
passphraseOpens,
|
|
53
|
+
} from "./archive/crypt.js";
|
|
54
|
+
import { signManifest } from "./archive/manifest-auth.js";
|
|
38
55
|
import { TarWriter } from "./archive/tar.js";
|
|
39
56
|
import { createCompressor } from "./archive/zstd.js";
|
|
40
57
|
import {
|
|
41
58
|
collectTree,
|
|
59
|
+
excludeForRoot,
|
|
42
60
|
expandUserPath,
|
|
43
|
-
isExcluded,
|
|
44
61
|
isInside,
|
|
45
62
|
EXCLUDE_RULES,
|
|
46
63
|
HOME_INCLUDES,
|
|
64
|
+
LOGIN_INCLUDES,
|
|
47
65
|
workspaceRoots,
|
|
48
66
|
type SourceEntry,
|
|
49
67
|
} from "./plan.js";
|
|
68
|
+
import { passphraseFilePath, resolvePassphrase } from "./passphrase.js";
|
|
69
|
+
import { discoverPlugins } from "./sources/plugins.js";
|
|
70
|
+
import {
|
|
71
|
+
discoverSessionRoots,
|
|
72
|
+
type SourceContext,
|
|
73
|
+
} from "./sources/sessions.js";
|
|
50
74
|
import {
|
|
51
75
|
STATE_PART,
|
|
52
76
|
indexSnapshot,
|
|
@@ -58,6 +82,7 @@ import {
|
|
|
58
82
|
} from "./store.js";
|
|
59
83
|
import type {
|
|
60
84
|
BackupSettings,
|
|
85
|
+
ExternalRoot,
|
|
61
86
|
Manifest,
|
|
62
87
|
SnapshotKind,
|
|
63
88
|
SnapshotPart,
|
|
@@ -65,6 +90,12 @@ import type {
|
|
|
65
90
|
|
|
66
91
|
/** Where the database copy lands inside the archive. */
|
|
67
92
|
const DB_MEMBER = "db/talon.db";
|
|
93
|
+
/** The part that holds WhatsApp auth and the userbot session. */
|
|
94
|
+
const LOGINS_PART = "logins.tar.zst";
|
|
95
|
+
/** How a clone reinstalls fetched plugins (see sources/plugins.ts). */
|
|
96
|
+
const PLUGINS_MANIFEST = "plugins-manifest.json";
|
|
97
|
+
/** Session transcripts and traces: large, churning, and their own part. */
|
|
98
|
+
const SESSIONS_PART = "sessions.tar.zst";
|
|
68
99
|
|
|
69
100
|
export type BuildOptions = {
|
|
70
101
|
kind: SnapshotKind;
|
|
@@ -77,6 +108,14 @@ export type BuildOptions = {
|
|
|
77
108
|
copyDatabase?: (destPath: string) => void;
|
|
78
109
|
/** Clock, for deterministic ids in tests. */
|
|
79
110
|
now?: Date;
|
|
111
|
+
/**
|
|
112
|
+
* The OS user's home, where backend session stores live. Defaults to
|
|
113
|
+
* `os.homedir()` for the real Talon home; a test that passes `home`
|
|
114
|
+
* without it gets no outside-the-home sources at all.
|
|
115
|
+
*/
|
|
116
|
+
userHome?: string | null;
|
|
117
|
+
/** Environment for store discovery (CODEX_HOME, …); same default rule. */
|
|
118
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
80
119
|
};
|
|
81
120
|
|
|
82
121
|
// ── Archive writing ─────────────────────────────────────────────────────────
|
|
@@ -107,20 +146,31 @@ async function addEntries(
|
|
|
107
146
|
}
|
|
108
147
|
}
|
|
109
148
|
|
|
149
|
+
/** A part's file name: `.enc` marks one written through the encryptor. */
|
|
150
|
+
function partName(base: string, passphrase: string | null): string {
|
|
151
|
+
return passphrase ? `${base}${ENCRYPTED_SUFFIX}` : base;
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
/**
|
|
111
|
-
* Write one compressed
|
|
112
|
-
*
|
|
113
|
-
*
|
|
155
|
+
* Write one compressed (and, with a passphrase, encrypted) part and
|
|
156
|
+
* return its size and digest. The digest is taken off the bytes as they
|
|
157
|
+
* land on disk, so verifying a part before extraction costs one pass
|
|
158
|
+
* over the file and no decompression.
|
|
114
159
|
*/
|
|
115
160
|
async function writePart(
|
|
116
161
|
destPath: string,
|
|
117
162
|
fill: (writer: TarWriter) => Promise<void>,
|
|
163
|
+
passphrase: string | null,
|
|
118
164
|
): Promise<{ bytes: number; sha256: string }> {
|
|
119
|
-
await mkdir(dirname(destPath), { recursive: true });
|
|
165
|
+
await mkdir(dirname(destPath), { recursive: true, mode: 0o700 });
|
|
120
166
|
const compressor = createCompressor();
|
|
121
167
|
const tap = new Sha256Tap();
|
|
122
|
-
|
|
123
|
-
|
|
168
|
+
// Owner-only even when encrypted: a plaintext local part holds every
|
|
169
|
+
// credential this install has.
|
|
170
|
+
const out = createWriteStream(destPath, { mode: 0o600 });
|
|
171
|
+
const flushed = passphrase
|
|
172
|
+
? pipeline(compressor, await createEncryptor(passphrase), tap, out)
|
|
173
|
+
: pipeline(compressor, tap, out);
|
|
124
174
|
try {
|
|
125
175
|
const writer = new TarWriter(compressor);
|
|
126
176
|
await fill(writer);
|
|
@@ -138,15 +188,38 @@ async function writePart(
|
|
|
138
188
|
|
|
139
189
|
// ── What goes in ────────────────────────────────────────────────────────────
|
|
140
190
|
|
|
191
|
+
type Collected = {
|
|
192
|
+
entries: SourceEntry[];
|
|
193
|
+
/** WhatsApp auth + userbot session, bound for their own part. */
|
|
194
|
+
logins: SourceEntry[];
|
|
195
|
+
includes: string[];
|
|
196
|
+
extras: { n: number; source: string }[];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Drop the passphrase file wherever it turned up (an extra path, the
|
|
201
|
+
* secrets folder): a key inside the backup it unlocks is no key at all.
|
|
202
|
+
*/
|
|
203
|
+
function withoutKeyFile(
|
|
204
|
+
entries: SourceEntry[],
|
|
205
|
+
keyFile: string | null,
|
|
206
|
+
): SourceEntry[] {
|
|
207
|
+
if (!keyFile) return entries;
|
|
208
|
+
const kept = entries.filter((entry) => resolve(entry.source) !== keyFile);
|
|
209
|
+
if (kept.length !== entries.length) {
|
|
210
|
+
logWarn(
|
|
211
|
+
"backup",
|
|
212
|
+
`Left the backup passphrase file ${keyFile} out of the snapshot — keep it outside backed-up paths`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return kept;
|
|
216
|
+
}
|
|
217
|
+
|
|
141
218
|
/** Everything under ~/.talon plus the configured workspace subset and extras. */
|
|
142
219
|
async function collectStateEntries(
|
|
143
220
|
home: string,
|
|
144
221
|
settings: BackupSettings,
|
|
145
|
-
): Promise<{
|
|
146
|
-
entries: SourceEntry[];
|
|
147
|
-
includes: string[];
|
|
148
|
-
extras: { n: number; source: string }[];
|
|
149
|
-
}> {
|
|
222
|
+
): Promise<Collected> {
|
|
150
223
|
const skipped: string[] = [];
|
|
151
224
|
const onSkip = (path: string, err: unknown) => {
|
|
152
225
|
skipped.push(
|
|
@@ -154,6 +227,7 @@ async function collectStateEntries(
|
|
|
154
227
|
);
|
|
155
228
|
};
|
|
156
229
|
const entries: SourceEntry[] = [];
|
|
230
|
+
const logins: SourceEntry[] = [];
|
|
157
231
|
const includes: string[] = [];
|
|
158
232
|
|
|
159
233
|
for (const root of HOME_INCLUDES) {
|
|
@@ -163,6 +237,15 @@ async function collectStateEntries(
|
|
|
163
237
|
includes.push(root);
|
|
164
238
|
}
|
|
165
239
|
}
|
|
240
|
+
if (settings.loginSessions !== "off") {
|
|
241
|
+
for (const root of LOGIN_INCLUDES) {
|
|
242
|
+
const found = await collectTree(join(home, root), root, { onSkip });
|
|
243
|
+
if (found.length > 0) {
|
|
244
|
+
logins.push(...found);
|
|
245
|
+
includes.push(root);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
166
249
|
for (const root of workspaceRoots(settings.workspaceInclude)) {
|
|
167
250
|
const archiveRoot = `workspace/${root}`;
|
|
168
251
|
const found = await collectTree(
|
|
@@ -206,22 +289,17 @@ async function collectStateEntries(
|
|
|
206
289
|
`Skipped ${skipped.length} unreadable path(s); first: ${skipped[0]}`,
|
|
207
290
|
);
|
|
208
291
|
}
|
|
209
|
-
|
|
292
|
+
const keyFile = passphraseFilePath(settings);
|
|
293
|
+
return {
|
|
294
|
+
entries: withoutKeyFile(entries, keyFile),
|
|
295
|
+
logins: withoutKeyFile(logins, keyFile),
|
|
296
|
+
includes,
|
|
297
|
+
extras,
|
|
298
|
+
};
|
|
210
299
|
}
|
|
211
300
|
|
|
212
301
|
// ── The memory palace part ──────────────────────────────────────────────────
|
|
213
302
|
|
|
214
|
-
/** Palace members, exempt from the rule that keeps them out of the state part. */
|
|
215
|
-
function palaceExclude(archivePath: string): boolean {
|
|
216
|
-
if (
|
|
217
|
-
archivePath === "workspace/palace" ||
|
|
218
|
-
archivePath.startsWith("workspace/palace/")
|
|
219
|
-
) {
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
return isExcluded(archivePath);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
303
|
/**
|
|
226
304
|
* Fingerprint the palace: path + size + mtime + content digest of every
|
|
227
305
|
* file. Content, not just mtime, because a restored or re-synced palace
|
|
@@ -253,25 +331,70 @@ async function palaceFingerprint(
|
|
|
253
331
|
async function buildPalacePart(
|
|
254
332
|
id: string,
|
|
255
333
|
home: string,
|
|
334
|
+
passphrase: string | null,
|
|
256
335
|
): Promise<{ part: SnapshotPart; palaceHash: string } | null> {
|
|
257
336
|
const palaceDir = join(home, "workspace", "palace");
|
|
258
337
|
const entries = await collectTree(palaceDir, "workspace/palace", {
|
|
259
|
-
exclude:
|
|
338
|
+
exclude: excludeForRoot("workspace/palace"),
|
|
260
339
|
});
|
|
261
340
|
if (entries.length === 0) return null;
|
|
262
341
|
|
|
263
342
|
const palaceHash = await palaceFingerprint(entries);
|
|
264
|
-
const name =
|
|
343
|
+
const name = partName(
|
|
344
|
+
`palace-${palaceHash.slice(0, 12)}.tar.zst`,
|
|
345
|
+
passphrase,
|
|
346
|
+
);
|
|
265
347
|
const dest = join(snapshotDir(id, home), name);
|
|
266
348
|
|
|
349
|
+
const reused = await reusePalacePart(
|
|
350
|
+
home,
|
|
351
|
+
palaceHash,
|
|
352
|
+
name,
|
|
353
|
+
dest,
|
|
354
|
+
passphrase,
|
|
355
|
+
);
|
|
356
|
+
if (reused) return { part: reused, palaceHash };
|
|
357
|
+
|
|
358
|
+
const written = await writePart(
|
|
359
|
+
dest,
|
|
360
|
+
(writer) => addEntries(writer, entries),
|
|
361
|
+
passphrase,
|
|
362
|
+
);
|
|
363
|
+
return {
|
|
364
|
+
part: {
|
|
365
|
+
name,
|
|
366
|
+
bytes: written.bytes,
|
|
367
|
+
sha256: written.sha256,
|
|
368
|
+
contentAddressed: true,
|
|
369
|
+
...(passphrase ? { encrypted: true } : {}),
|
|
370
|
+
},
|
|
371
|
+
palaceHash,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Hard-link an identical palace part from an older snapshot, if there is
|
|
377
|
+
* one. An encrypted part is only reused when the current passphrase opens
|
|
378
|
+
* it — after a key change the palace is re-encrypted rather than carried
|
|
379
|
+
* forward under a key the operator may no longer hold.
|
|
380
|
+
*/
|
|
381
|
+
async function reusePalacePart(
|
|
382
|
+
home: string,
|
|
383
|
+
palaceHash: string,
|
|
384
|
+
name: string,
|
|
385
|
+
dest: string,
|
|
386
|
+
passphrase: string | null,
|
|
387
|
+
): Promise<SnapshotPart | null> {
|
|
267
388
|
for (const previous of await listLocalManifests(home)) {
|
|
268
389
|
if (previous.palaceHash !== palaceHash) continue;
|
|
269
390
|
const reusable = previous.parts.find((part) => part.name === name);
|
|
270
391
|
if (!reusable) continue;
|
|
392
|
+
const source = join(snapshotDir(previous.id, home), name);
|
|
393
|
+
if (passphrase && !(await passphraseOpens(source, passphrase))) break;
|
|
271
394
|
try {
|
|
272
|
-
await linkOrCopy(
|
|
395
|
+
await linkOrCopy(source, dest);
|
|
273
396
|
log("backup", `Reused palace part from ${previous.id} (${name})`);
|
|
274
|
-
return {
|
|
397
|
+
return { ...reusable, contentAddressed: true };
|
|
275
398
|
} catch (err) {
|
|
276
399
|
logWarn(
|
|
277
400
|
"backup",
|
|
@@ -280,18 +403,176 @@ async function buildPalacePart(
|
|
|
280
403
|
break;
|
|
281
404
|
}
|
|
282
405
|
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Outside the home: plugins and sessions ─────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/** config.json as data — the snapshot reads what it is about to archive. */
|
|
412
|
+
async function readConfigJson(home: string): Promise<Record<string, unknown>> {
|
|
413
|
+
try {
|
|
414
|
+
const parsed: unknown = JSON.parse(
|
|
415
|
+
await readFile(join(home, "config.json"), "utf8"),
|
|
416
|
+
);
|
|
417
|
+
return parsed && typeof parsed === "object"
|
|
418
|
+
? (parsed as Record<string, unknown>)
|
|
419
|
+
: {};
|
|
420
|
+
} catch {
|
|
421
|
+
return {};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function sourceContext(
|
|
426
|
+
options: BuildOptions,
|
|
427
|
+
home: string,
|
|
428
|
+
): Promise<SourceContext> {
|
|
429
|
+
const realHome = options.home === undefined;
|
|
430
|
+
return {
|
|
431
|
+
home,
|
|
432
|
+
userHome:
|
|
433
|
+
options.userHome !== undefined
|
|
434
|
+
? options.userHome
|
|
435
|
+
: realHome
|
|
436
|
+
? homedir()
|
|
437
|
+
: null,
|
|
438
|
+
env: options.env ?? (realHome ? process.env : {}),
|
|
439
|
+
config: await readConfigJson(home),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
283
442
|
|
|
284
|
-
|
|
285
|
-
|
|
443
|
+
type CollectedExternal = {
|
|
444
|
+
entries: SourceEntry[];
|
|
445
|
+
includes: string[];
|
|
446
|
+
external: ExternalRoot[];
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/** Walk external directory roots into archive members. */
|
|
450
|
+
async function collectExternal(
|
|
451
|
+
roots: readonly ExternalRoot[],
|
|
452
|
+
): Promise<CollectedExternal> {
|
|
453
|
+
const collected: CollectedExternal = {
|
|
454
|
+
entries: [],
|
|
455
|
+
includes: [],
|
|
456
|
+
external: [],
|
|
457
|
+
};
|
|
458
|
+
for (const root of roots) {
|
|
459
|
+
const found = await collectTree(root.source, root.root, {
|
|
460
|
+
exclude: excludeForRoot(root.root),
|
|
461
|
+
});
|
|
462
|
+
if (found.length === 0) continue;
|
|
463
|
+
collected.entries.push(...found);
|
|
464
|
+
collected.includes.push(root.root);
|
|
465
|
+
collected.external.push(root);
|
|
466
|
+
}
|
|
467
|
+
return collected;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Plugin checkouts plus `plugins-manifest.json` (written next to the part
|
|
472
|
+
* and archived as a file at the root, so a restore leaves it in the home).
|
|
473
|
+
*/
|
|
474
|
+
async function collectPlugins(
|
|
475
|
+
ctx: SourceContext,
|
|
476
|
+
dir: string,
|
|
477
|
+
): Promise<CollectedExternal> {
|
|
478
|
+
const { manifest, roots } = await discoverPlugins(ctx);
|
|
479
|
+
const collected = await collectExternal(roots);
|
|
480
|
+
const manifestPath = join(dir, `${PLUGINS_MANIFEST}.tmp`);
|
|
481
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", {
|
|
482
|
+
mode: 0o600,
|
|
483
|
+
});
|
|
484
|
+
const { size, mtimeMs } = await stat(manifestPath);
|
|
485
|
+
collected.entries.push({
|
|
486
|
+
archivePath: PLUGINS_MANIFEST,
|
|
487
|
+
source: manifestPath,
|
|
488
|
+
type: "file",
|
|
489
|
+
mode: 0o600,
|
|
490
|
+
mtime: Math.floor(mtimeMs / 1000),
|
|
491
|
+
size,
|
|
492
|
+
});
|
|
493
|
+
collected.includes.push(PLUGINS_MANIFEST);
|
|
494
|
+
return collected;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Consistent copies of the SQLite files among the session roots. A store
|
|
499
|
+
* that SQLite cannot open is skipped with a warning rather than copied
|
|
500
|
+
* byte-wise — a torn copy would restore as a corrupt session store.
|
|
501
|
+
*/
|
|
502
|
+
async function copySqliteRoots(
|
|
503
|
+
roots: readonly ExternalRoot[],
|
|
504
|
+
dir: string,
|
|
505
|
+
): Promise<CollectedExternal> {
|
|
506
|
+
const collected: CollectedExternal = {
|
|
507
|
+
entries: [],
|
|
508
|
+
includes: [],
|
|
509
|
+
external: [],
|
|
510
|
+
};
|
|
511
|
+
for (const [index, root] of roots.entries()) {
|
|
512
|
+
const temp = join(dir, `sqlite-${index}.tmp`);
|
|
513
|
+
try {
|
|
514
|
+
await rm(temp, { force: true });
|
|
515
|
+
snapshotSqliteFile(root.source, temp);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
logWarn("backup", `Skipped ${root.source}: ${String(err)}`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const { size } = await stat(temp);
|
|
521
|
+
collected.entries.push({
|
|
522
|
+
archivePath: root.root,
|
|
523
|
+
source: temp,
|
|
524
|
+
type: "file",
|
|
525
|
+
mode: 0o600,
|
|
526
|
+
mtime: Math.floor(Date.now() / 1000),
|
|
527
|
+
size,
|
|
528
|
+
});
|
|
529
|
+
collected.includes.push(root.root);
|
|
530
|
+
collected.external.push(root);
|
|
531
|
+
}
|
|
532
|
+
return collected;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The sessions part: backend transcripts, session databases, traces. */
|
|
536
|
+
async function buildSessionsPart(
|
|
537
|
+
dir: string,
|
|
538
|
+
ctx: SourceContext,
|
|
539
|
+
passphrase: string | null,
|
|
540
|
+
): Promise<{ part: SnapshotPart; collected: CollectedExternal } | null> {
|
|
541
|
+
const roots = await discoverSessionRoots(ctx);
|
|
542
|
+
const trees = await collectExternal(roots.filter((r) => !r.sqlite));
|
|
543
|
+
const traces = await collectTree(
|
|
544
|
+
join(ctx.home, "data", "traces"),
|
|
545
|
+
"data/traces",
|
|
546
|
+
{ exclude: excludeForRoot("data/traces") },
|
|
547
|
+
);
|
|
548
|
+
const databases = await copySqliteRoots(
|
|
549
|
+
roots.filter((r) => r.sqlite),
|
|
550
|
+
dir,
|
|
551
|
+
);
|
|
552
|
+
const entries = [...trees.entries, ...traces, ...databases.entries];
|
|
553
|
+
if (entries.length === 0) return null;
|
|
554
|
+
const name = partName(SESSIONS_PART, passphrase);
|
|
555
|
+
const written = await writePart(
|
|
556
|
+
join(dir, name),
|
|
557
|
+
(writer) => addEntries(writer, entries),
|
|
558
|
+
passphrase,
|
|
286
559
|
);
|
|
287
560
|
return {
|
|
288
561
|
part: {
|
|
289
562
|
name,
|
|
290
563
|
bytes: written.bytes,
|
|
291
564
|
sha256: written.sha256,
|
|
292
|
-
|
|
565
|
+
...(passphrase ? { encrypted: true } : {}),
|
|
566
|
+
},
|
|
567
|
+
collected: {
|
|
568
|
+
entries,
|
|
569
|
+
includes: [
|
|
570
|
+
...trees.includes,
|
|
571
|
+
...(traces.length > 0 ? ["data/traces"] : []),
|
|
572
|
+
...databases.includes,
|
|
573
|
+
],
|
|
574
|
+
external: [...trees.external, ...databases.external],
|
|
293
575
|
},
|
|
294
|
-
palaceHash,
|
|
295
576
|
};
|
|
296
577
|
}
|
|
297
578
|
|
|
@@ -320,6 +601,71 @@ async function readGitHead(startDir: string): Promise<string | undefined> {
|
|
|
320
601
|
|
|
321
602
|
// ── The build ───────────────────────────────────────────────────────────────
|
|
322
603
|
|
|
604
|
+
/**
|
|
605
|
+
* The state part: identity, state, workspace subset, extras, the plugin
|
|
606
|
+
* checkouts and the database copy.
|
|
607
|
+
*/
|
|
608
|
+
async function writeStatePart(
|
|
609
|
+
dir: string,
|
|
610
|
+
entries: readonly SourceEntry[],
|
|
611
|
+
passphrase: string | null,
|
|
612
|
+
options: BuildOptions,
|
|
613
|
+
): Promise<SnapshotPart> {
|
|
614
|
+
const dbTemp = join(dir, "db-snapshot.tmp");
|
|
615
|
+
await rm(dbTemp, { force: true });
|
|
616
|
+
(options.copyDatabase ?? snapshotDatabase)(dbTemp);
|
|
617
|
+
const dbStat = await stat(dbTemp);
|
|
618
|
+
const name = partName(STATE_PART, passphrase);
|
|
619
|
+
const written = await writePart(
|
|
620
|
+
join(dir, name),
|
|
621
|
+
async (writer) => {
|
|
622
|
+
await addEntries(writer, entries);
|
|
623
|
+
await writer.addFile(
|
|
624
|
+
DB_MEMBER,
|
|
625
|
+
dbTemp,
|
|
626
|
+
0o600,
|
|
627
|
+
Math.floor(Date.now() / 1000),
|
|
628
|
+
dbStat.size,
|
|
629
|
+
);
|
|
630
|
+
},
|
|
631
|
+
passphrase,
|
|
632
|
+
);
|
|
633
|
+
await rm(dbTemp, { force: true });
|
|
634
|
+
return { name, ...written, ...(passphrase ? { encrypted: true } : {}) };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The login-sessions part, when there is anything to put in it. Marked
|
|
639
|
+
* local-only unless the operator opted in to shipping sessions off-host.
|
|
640
|
+
*/
|
|
641
|
+
async function writeLoginsPart(
|
|
642
|
+
dir: string,
|
|
643
|
+
entries: readonly SourceEntry[],
|
|
644
|
+
passphrase: string | null,
|
|
645
|
+
settings: BackupSettings,
|
|
646
|
+
): Promise<SnapshotPart | null> {
|
|
647
|
+
if (entries.length === 0) return null;
|
|
648
|
+
const name = partName(LOGINS_PART, passphrase);
|
|
649
|
+
const written = await writePart(
|
|
650
|
+
join(dir, name),
|
|
651
|
+
(writer) => addEntries(writer, entries),
|
|
652
|
+
passphrase,
|
|
653
|
+
);
|
|
654
|
+
return {
|
|
655
|
+
name,
|
|
656
|
+
...written,
|
|
657
|
+
...(passphrase ? { encrypted: true } : {}),
|
|
658
|
+
...(settings.loginSessions === "remote" ? {} : { localOnly: true }),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Delete the scratch files a build leaves beside its parts. */
|
|
663
|
+
async function removeScratch(dir: string): Promise<void> {
|
|
664
|
+
for (const name of await readdir(dir)) {
|
|
665
|
+
if (name.endsWith(".tmp")) await rm(join(dir, name), { force: true });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
323
669
|
/**
|
|
324
670
|
* Build one snapshot end to end: collect, archive, hash, write the
|
|
325
671
|
* manifest, index it. Leaves nothing behind on failure — a half-written
|
|
@@ -328,45 +674,54 @@ async function readGitHead(startDir: string): Promise<string | undefined> {
|
|
|
328
674
|
*/
|
|
329
675
|
export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
|
|
330
676
|
const home = options.home ?? dirs.root;
|
|
677
|
+
// Resolved before anything is written: a configured-but-broken key must
|
|
678
|
+
// fail the snapshot, never degrade it to plaintext.
|
|
679
|
+
const passphrase = await resolvePassphrase(options.settings);
|
|
331
680
|
const id = newSnapshotId(options.now ?? new Date());
|
|
332
681
|
const dir = snapshotDir(id, home);
|
|
333
682
|
const started = Date.now();
|
|
334
|
-
await mkdir(dir, { recursive: true });
|
|
683
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
335
684
|
|
|
336
685
|
try {
|
|
337
|
-
const
|
|
338
|
-
|
|
686
|
+
const ctx = await sourceContext(options, home);
|
|
687
|
+
const state = await collectStateEntries(home, options.settings);
|
|
688
|
+
const plugins = await collectPlugins(ctx, dir);
|
|
689
|
+
const parts: SnapshotPart[] = [
|
|
690
|
+
await writeStatePart(
|
|
691
|
+
dir,
|
|
692
|
+
[...state.entries, ...plugins.entries],
|
|
693
|
+
passphrase,
|
|
694
|
+
options,
|
|
695
|
+
),
|
|
696
|
+
];
|
|
697
|
+
const logins = await writeLoginsPart(
|
|
698
|
+
dir,
|
|
699
|
+
state.logins,
|
|
700
|
+
passphrase,
|
|
339
701
|
options.settings,
|
|
340
702
|
);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const dbStat = await stat(dbTemp);
|
|
703
|
+
if (logins) parts.push(logins);
|
|
704
|
+
const includes = [...state.includes, ...plugins.includes];
|
|
705
|
+
const external = [...plugins.external];
|
|
345
706
|
|
|
346
|
-
|
|
347
|
-
await
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
);
|
|
355
|
-
});
|
|
356
|
-
await rm(dbTemp, { force: true });
|
|
357
|
-
|
|
358
|
-
const parts: SnapshotPart[] = [
|
|
359
|
-
{ name: STATE_PART, bytes: state.bytes, sha256: state.sha256 },
|
|
360
|
-
];
|
|
707
|
+
if (options.settings.includeSessions) {
|
|
708
|
+
const sessions = await buildSessionsPart(dir, ctx, passphrase);
|
|
709
|
+
if (sessions) {
|
|
710
|
+
parts.push(sessions.part);
|
|
711
|
+
includes.push(...sessions.collected.includes);
|
|
712
|
+
external.push(...sessions.collected.external);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
361
715
|
let palaceHash: string | undefined;
|
|
362
716
|
if (options.settings.includePalace) {
|
|
363
|
-
const palace = await buildPalacePart(id, home);
|
|
717
|
+
const palace = await buildPalacePart(id, home, passphrase);
|
|
364
718
|
if (palace) {
|
|
365
719
|
parts.push(palace.part);
|
|
366
720
|
palaceHash = palace.palaceHash;
|
|
367
721
|
includes.push("workspace/palace");
|
|
368
722
|
}
|
|
369
723
|
}
|
|
724
|
+
await removeScratch(dir);
|
|
370
725
|
|
|
371
726
|
const gitHead = await readGitHead(process.cwd());
|
|
372
727
|
const manifest: Manifest = {
|
|
@@ -382,11 +737,14 @@ export async function buildSnapshot(options: BuildOptions): Promise<Manifest> {
|
|
|
382
737
|
parts,
|
|
383
738
|
includes: [...includes, DB_MEMBER],
|
|
384
739
|
excludes: [...EXCLUDE_RULES],
|
|
385
|
-
...(extras.length > 0 ? { extras } : {}),
|
|
740
|
+
...(state.extras.length > 0 ? { extras: state.extras } : {}),
|
|
741
|
+
...(external.length > 0 ? { external } : {}),
|
|
742
|
+
...(ctx.userHome ? { origin: { userHome: ctx.userHome, home } } : {}),
|
|
386
743
|
...(palaceHash ? { palaceHash } : {}),
|
|
387
744
|
sizeBytes: parts.reduce((sum, part) => sum + part.bytes, 0),
|
|
388
745
|
remote: {},
|
|
389
746
|
};
|
|
747
|
+
if (passphrase) manifest.auth = await signManifest(manifest, passphrase);
|
|
390
748
|
await writeManifest(manifest, home);
|
|
391
749
|
indexSnapshot(manifest);
|
|
392
750
|
log(
|