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.
Files changed (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The snapshot builder — one run, one directory, two parts.
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 { mkdir, readFile, rm, stat } from "node:fs/promises";
24
- import { hostname } from "node:os";
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 { snapshotDatabase } from "../../storage/backup/index.js";
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 part and return its size and digest. The digest is
112
- * taken off the compressed bytes, so verifying a part before extraction
113
- * costs one pass over the file and no decompression.
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
- const out = createWriteStream(destPath);
123
- const flushed = pipeline(compressor, tap, out);
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
- return { entries, includes, extras };
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: palaceExclude,
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 = `palace-${palaceHash.slice(0, 12)}.tar.zst`;
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(join(snapshotDir(previous.id, home), name), dest);
395
+ await linkOrCopy(source, dest);
273
396
  log("backup", `Reused palace part from ${previous.id} (${name})`);
274
- return { part: { ...reusable, contentAddressed: true }, palaceHash };
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
- const written = await writePart(dest, (writer) =>
285
- addEntries(writer, entries),
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
- contentAddressed: true,
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 { entries, includes, extras } = await collectStateEntries(
338
- home,
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
- const dbTemp = join(dir, "db-snapshot.tmp");
342
- await rm(dbTemp, { force: true });
343
- (options.copyDatabase ?? snapshotDatabase)(dbTemp);
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
- const state = await writePart(join(dir, STATE_PART), async (writer) => {
347
- await addEntries(writer, entries);
348
- await writer.addFile(
349
- DB_MEMBER,
350
- dbTemp,
351
- 0o600,
352
- Math.floor(Date.now() / 1000),
353
- dbStat.size,
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(