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
@@ -52,6 +52,8 @@ export const DEFAULT_BACKUP_SETTINGS = {
52
52
  keepLocal: 12,
53
53
  keepRemote: 30,
54
54
  includePalace: true,
55
+ loginSessions: "local",
56
+ includeSessions: true,
55
57
  workspaceInclude: DEFAULT_WORKSPACE_INCLUDE,
56
58
  extraPaths: [] as readonly string[],
57
59
  checkpointBeforeUpdate: true,
@@ -70,14 +72,26 @@ export const HOME_INCLUDES: readonly string[] = [
70
72
  "prompts",
71
73
  "data",
72
74
  "keys",
73
- "whatsapp-auth",
74
- ".user-session",
75
+ "google",
76
+ "plugins",
75
77
  "mesh-devices.json",
76
78
  "mesh-history.json",
79
+ "mesh-locations.json",
77
80
  "teleport-state.json",
78
81
  "agent-workspace",
79
82
  ];
80
83
 
84
+ /**
85
+ * Login sessions — the WhatsApp pairing and the userbot's Telegram login
86
+ * (the operator's own account). They get their own part so they can stay
87
+ * on this machine: a stolen remote copy must not be a logged-in session.
88
+ * Re-linking after a disaster is a QR scan; losing the account is not.
89
+ */
90
+ export const LOGIN_INCLUDES: readonly string[] = [
91
+ "whatsapp-auth",
92
+ ".user-session",
93
+ ];
94
+
81
95
  /** The exclusion rules, in the words the manifest records them by. */
82
96
  export const EXCLUDE_RULES: readonly string[] = [
83
97
  "talon.log*",
@@ -86,8 +100,9 @@ export const EXCLUDE_RULES: readonly string[] = [
86
100
  "*venv*/",
87
101
  "ns/ (FUSE mount — never stat()ed)",
88
102
  "backups/",
89
- "data/traces/**",
103
+ "data/traces/** (in the sessions part)",
90
104
  "data/talon.db* (the database is added via VACUUM INTO)",
105
+ "plugin-src/**/{dist,build,.venv,cache,.cache,__pycache__}/",
91
106
  "*.tmp-*",
92
107
  "workspace/palace/** (its own part)",
93
108
  ];
@@ -101,7 +116,6 @@ export function isExcluded(path: string): boolean {
101
116
  const segments = path.split("/").filter(Boolean);
102
117
  if (segments.length === 0) return true;
103
118
  const first = segments[0];
104
- const last = segments[segments.length - 1];
105
119
  // Anchored rules — only at the root of the archive.
106
120
  if (first === "ns" || first === "backups") return true;
107
121
  if (
@@ -118,19 +132,58 @@ export function isExcluded(path: string): boolean {
118
132
  }
119
133
  if (path === "workspace/palace" || path.startsWith("workspace/palace/"))
120
134
  return true;
121
- // Rules that hold at any depth: build output, virtualenvs, half-written
122
- // files from an atomic write that never landed.
123
- if (
124
- segments.some(
125
- (s) => s === "node-bin" || s === "node_modules" || s.includes("venv"),
126
- )
127
- ) {
135
+ if (isExcludedAnywhere(path)) return true;
136
+ // Plugin sources: the code and its lockfiles, not what a build or an
137
+ // install regenerates from them.
138
+ if (first === "plugin-src" && segments.some((s) => PLUGIN_BUILD_DIRS.has(s)))
128
139
  return true;
129
- }
130
- if (last.includes(".tmp-")) return true;
131
140
  return false;
132
141
  }
133
142
 
143
+ /** Build and cache output inside a plugin checkout — regenerated on install. */
144
+ const PLUGIN_BUILD_DIRS = new Set([
145
+ "dist",
146
+ "build",
147
+ ".venv",
148
+ "cache",
149
+ ".cache",
150
+ "__pycache__",
151
+ ]);
152
+
153
+ /** Archive roots that live in their own part, exempt from {@link isExcluded}. */
154
+ const OWN_PART_ROOTS = ["workspace/palace", "data/traces"] as const;
155
+
156
+ /**
157
+ * The exclusion rule for one include root. The palace and the traces are
158
+ * kept out of the state part by {@link isExcluded} because they travel in
159
+ * parts of their own; walking those roots (to build their part, or to
160
+ * restore them) must therefore not apply that same rule to them.
161
+ */
162
+ export function excludeForRoot(root: string): (archivePath: string) => boolean {
163
+ const own = OWN_PART_ROOTS.find(
164
+ (prefix) => root === prefix || root.startsWith(`${prefix}/`),
165
+ );
166
+ if (!own) return isExcluded;
167
+ return (archivePath) =>
168
+ archivePath === own || archivePath.startsWith(`${own}/`)
169
+ ? isExcludedAnywhere(archivePath)
170
+ : isExcluded(archivePath);
171
+ }
172
+
173
+ /**
174
+ * The rules that hold at any depth: build output, virtualenvs, and
175
+ * half-written files from an atomic write that never landed.
176
+ */
177
+ function isExcludedAnywhere(path: string): boolean {
178
+ const segments = path.split("/").filter(Boolean);
179
+ const last = segments[segments.length - 1] ?? "";
180
+ return (
181
+ segments.some(
182
+ (s) => s === "node-bin" || s === "node_modules" || s.includes("venv"),
183
+ ) || last.includes(".tmp-")
184
+ );
185
+ }
186
+
134
187
  /**
135
188
  * Match one workspace-relative path against the `workspaceInclude` list.
136
189
  * The pattern language is deliberately two rules wide — an exact relative
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What a restore must check before it trusts a snapshot, and the file
3
+ * modes it leaves behind.
4
+ *
5
+ * A manifest can come from anywhere — copied off a remote target, handed
6
+ * over on a USB stick — and it decides what gets extracted where. So:
7
+ *
8
+ * - A manifest with an `auth` block must verify under the configured
9
+ * passphrase, and then every part it lists must be encrypted: an
10
+ * authenticated manifest pointing at a plaintext part is a swap.
11
+ * - A manifest without one is a legacy (or plaintext) snapshot. It
12
+ * still restores — unless this install has a backup passphrase or
13
+ * the parts came from a remote target, because that is exactly what
14
+ * an attacker who stripped the MAC would hand us. Then the operator
15
+ * has to say so explicitly (`--allow-unauthenticated`).
16
+ *
17
+ * Everything a restore writes is owner-only: files keep their owner
18
+ * bits (so scripts stay executable) and gain nothing for group/other;
19
+ * directories are 0700. The snapshot carries config.json, keys and
20
+ * sessions — whatever mode they had on the old machine, this one should
21
+ * not publish them.
22
+ */
23
+
24
+ import { chmod } from "node:fs/promises";
25
+ import { logWarn } from "../../util/log.js";
26
+ import { TalonError } from "../errors.js";
27
+ import { verifyManifest } from "./archive/manifest-auth.js";
28
+ import { resolvePassphrase } from "./passphrase.js";
29
+ import type { BackupSettings, Manifest } from "./types.js";
30
+
31
+ export type ManifestTrust = {
32
+ /** Operator override for unauthenticated manifests. */
33
+ allowUnauthenticated?: boolean;
34
+ /** True when any part is being fetched from a remote target. */
35
+ fromRemote?: boolean;
36
+ };
37
+
38
+ function refuse(message: string): TalonError {
39
+ return new TalonError(message, { reason: "bad_request" });
40
+ }
41
+
42
+ /**
43
+ * Throw unless this manifest may be trusted. Returns whether it was
44
+ * authenticated, so the part check can insist on encryption.
45
+ */
46
+ export async function authenticateManifest(
47
+ manifest: Manifest,
48
+ settings: Pick<BackupSettings, "encryption">,
49
+ trust: ManifestTrust = {},
50
+ ): Promise<boolean> {
51
+ const passphrase = await resolvePassphrase(settings);
52
+ if (manifest.auth) {
53
+ if (!passphrase) {
54
+ throw refuse(
55
+ `Snapshot ${manifest.id} is signed with a backup passphrase — set backup.encryption.passphraseFile or TALON_BACKUP_PASSPHRASE to restore it`,
56
+ );
57
+ }
58
+ if (!(await verifyManifest(manifest, passphrase))) {
59
+ throw refuse(
60
+ `Snapshot ${manifest.id}: manifest authentication failed — wrong passphrase, or the manifest was modified; restore aborted`,
61
+ );
62
+ }
63
+ return true;
64
+ }
65
+ if (trust.allowUnauthenticated) {
66
+ logWarn(
67
+ "backup",
68
+ `Restoring unauthenticated snapshot ${manifest.id} (--allow-unauthenticated)`,
69
+ );
70
+ return false;
71
+ }
72
+ if (passphrase || trust.fromRemote) {
73
+ throw refuse(
74
+ `Snapshot ${manifest.id} has no manifest signature` +
75
+ (trust.fromRemote
76
+ ? " and its parts come from a remote target"
77
+ : " although this install has a backup passphrase") +
78
+ ` — it may have been tampered with. If you know it is a snapshot from before backup encryption, re-run with --allow-unauthenticated`,
79
+ );
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** Owner-only mode for a restored file, keeping the owner's execute bit. */
85
+ export function privateFileMode(mode: number): number {
86
+ return (mode & 0o700) | 0o600;
87
+ }
88
+
89
+ /** Tighten one restored path. Failures are logged, not fatal. */
90
+ export async function makePrivate(
91
+ path: string,
92
+ type: "file" | "dir" | "symlink",
93
+ mode: number,
94
+ ): Promise<void> {
95
+ if (type === "symlink" || process.platform === "win32") return;
96
+ try {
97
+ await chmod(path, type === "dir" ? 0o700 : privateFileMode(mode));
98
+ } catch (err) {
99
+ logWarn("backup", `Could not set mode on ${path}: ${String(err)}`);
100
+ }
101
+ }
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * The rules that make this safe to run on a live home directory:
5
5
  *
6
- * 1. Verify before you touch anything. Every part's sha256 is checked
7
- * against the manifest first; a part that fails is a stopped
8
- * restore, not a half-applied one.
6
+ * 1. Verify before you touch anything. The manifest's signature is
7
+ * checked (restore-guard.ts), then every part's sha256 and — for
8
+ * encrypted parts — every record's tag; a part that fails is a
9
+ * stopped restore, not a half-applied one.
9
10
  * 2. Stage, then swap. The archive is extracted into a staging
10
11
  * directory beside the snapshot (same filesystem, so the swap is
11
12
  * renames), and only then do the live paths change.
@@ -23,6 +24,8 @@
23
24
  */
24
25
 
25
26
  import { createReadStream } from "node:fs";
27
+ import { pipeline } from "node:stream";
28
+ import type { Readable } from "node:stream";
26
29
  import {
27
30
  mkdir,
28
31
  readFile,
@@ -33,19 +36,36 @@ import {
33
36
  unlink,
34
37
  copyFile,
35
38
  } from "node:fs/promises";
39
+ import { homedir } from "node:os";
36
40
  import { dirname, join } from "node:path";
37
41
  import writeFileAtomic from "write-file-atomic";
38
42
  import { dirs } from "../../util/paths.js";
39
43
  import { log, logWarn } from "../../util/log.js";
40
44
  import { TalonError } from "../errors.js";
45
+ import {
46
+ isEncryptedFile,
47
+ openDecrypted,
48
+ verifyDecryptable,
49
+ } from "./archive/crypt.js";
41
50
  import { sha256File } from "./archive/digest.js";
42
51
  import { extractTar } from "./archive/tar.js";
43
52
  import { createDecompressor } from "./archive/zstd.js";
44
- import { collectTree } from "./plan.js";
53
+ import { requirePassphrase } from "./passphrase.js";
54
+ import { collectTree, excludeForRoot } from "./plan.js";
55
+ import {
56
+ authenticateManifest,
57
+ makePrivate,
58
+ type ManifestTrust,
59
+ } from "./restore-guard.js";
45
60
  import { buildSnapshot } from "./snapshot.js";
61
+ import {
62
+ relocateRoot,
63
+ rewriteConfigForClone,
64
+ type CloneTarget,
65
+ } from "./sources/relocate.js";
46
66
  import { isSnapshotId, partPath, readManifest, snapshotDir } from "./store.js";
47
67
  import type { BackupTarget } from "./targets.js";
48
- import type { BackupSettings, Manifest } from "./types.js";
68
+ import type { BackupSettings, Manifest, SnapshotPart } from "./types.js";
49
69
 
50
70
  /** A staged request older than this is stale and ignored. */
51
71
  export const RESTORE_PENDING_MAX_AGE_MS = 10 * 60_000;
@@ -67,6 +87,8 @@ export type RestoreReport = {
67
87
  written: Record<string, number>;
68
88
  removed: number;
69
89
  databaseReplaced: boolean;
90
+ /** A clone rewrote config.json's paths for this machine. */
91
+ configRewritten?: boolean;
70
92
  };
71
93
 
72
94
  export function restorePendingPath(home: string = dirs.root): string {
@@ -143,19 +165,49 @@ export async function readRestorePending(
143
165
 
144
166
  // ── Parts ───────────────────────────────────────────────────────────────────
145
167
 
146
- /** Fetch any part that is not on local disk from the given target. */
168
+ async function isLocal(path: string): Promise<boolean> {
169
+ try {
170
+ await stat(path);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /** The parts of this snapshot that are not on local disk. */
178
+ async function missingParts(
179
+ manifest: Manifest,
180
+ home: string,
181
+ ): Promise<SnapshotPart[]> {
182
+ const missing: SnapshotPart[] = [];
183
+ for (const part of manifest.parts) {
184
+ if (!(await isLocal(partPath(manifest.id, part.name, home)))) {
185
+ missing.push(part);
186
+ }
187
+ }
188
+ return missing;
189
+ }
190
+
191
+ /**
192
+ * Fetch every missing part from the given target and return the parts
193
+ * the restore will use. A missing local-only part (login sessions kept
194
+ * off remotes) is skipped with a warning — the restore goes on without it.
195
+ */
147
196
  async function ensureParts(
148
197
  manifest: Manifest,
149
198
  home: string,
199
+ missing: readonly SnapshotPart[],
150
200
  target?: BackupTarget,
151
- ): Promise<void> {
152
- for (const part of manifest.parts) {
153
- const path = partPath(manifest.id, part.name, home);
154
- try {
155
- await stat(path);
201
+ ): Promise<SnapshotPart[]> {
202
+ const skipped = new Set<string>();
203
+ for (const part of missing) {
204
+ if (part.localOnly) {
205
+ logWarn(
206
+ "backup",
207
+ `${part.name} was kept on the original machine only — restoring without it (re-link WhatsApp / the userbot afterwards)`,
208
+ );
209
+ skipped.add(part.name);
156
210
  continue;
157
- } catch {
158
- /* not here — fall through to the download */
159
211
  }
160
212
  if (!target) {
161
213
  throw new TalonError(
@@ -163,18 +215,31 @@ async function ensureParts(
163
215
  { reason: "bad_request" },
164
216
  );
165
217
  }
218
+ const path = partPath(manifest.id, part.name, home);
166
219
  log("backup", `Downloading ${part.name} from ${target.id}…`);
167
- await mkdir(dirname(path), { recursive: true });
220
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
168
221
  await target.download(manifest.id, part.name, path);
169
222
  }
223
+ return manifest.parts.filter((part) => !skipped.has(part.name));
170
224
  }
171
225
 
172
- /** Check every part against the manifest. Throws on the first mismatch. */
226
+ /** Who may read backups: only the settings' encryption block matters. */
227
+ type KeySettings = Pick<BackupSettings, "encryption">;
228
+
229
+ /**
230
+ * Check every part against the manifest. Throws on the first mismatch.
231
+ * An encrypted part is also decrypted end to end (and discarded), so a
232
+ * wrong passphrase or a tampered byte stops the restore before a single
233
+ * file is extracted — the manifest's digest alone cannot prove that, as
234
+ * the manifest travels with the parts.
235
+ */
173
236
  export async function verifyParts(
174
237
  manifest: Manifest,
175
238
  home: string,
239
+ settings: KeySettings = {},
240
+ parts: readonly SnapshotPart[] = manifest.parts,
176
241
  ): Promise<void> {
177
- for (const part of manifest.parts) {
242
+ for (const part of parts) {
178
243
  const path = partPath(manifest.id, part.name, home);
179
244
  const actual = await sha256File(path);
180
245
  if (actual !== part.sha256) {
@@ -183,30 +248,78 @@ export async function verifyParts(
183
248
  { reason: "bad_request" },
184
249
  );
185
250
  }
251
+ if (!(await isEncryptedFile(path))) {
252
+ // A signed manifest only ever lists encrypted parts; a plaintext
253
+ // one here was swapped in.
254
+ if (manifest.auth) {
255
+ throw new TalonError(
256
+ `Part ${part.name} of ${manifest.id} is not encrypted although its manifest is signed — restore aborted`,
257
+ { reason: "bad_request" },
258
+ );
259
+ }
260
+ continue;
261
+ }
262
+ const passphrase = await requirePassphrase(
263
+ settings,
264
+ `Snapshot ${manifest.id}`,
265
+ );
266
+ try {
267
+ await verifyDecryptable(path, passphrase);
268
+ } catch (err) {
269
+ throw new TalonError(
270
+ `Part ${part.name} of ${manifest.id} cannot be decrypted — ${err instanceof Error ? err.message : String(err)}; restore aborted`,
271
+ { reason: "bad_request", cause: err },
272
+ );
273
+ }
186
274
  }
187
275
  }
188
276
 
277
+ /** A part's archive bytes: decrypted when the file carries the header. */
278
+ async function openPart(
279
+ path: string,
280
+ settings: KeySettings,
281
+ ): Promise<Readable> {
282
+ if (!(await isEncryptedFile(path))) return createReadStream(path);
283
+ return openDecrypted(path, await requirePassphrase(settings, path));
284
+ }
285
+
189
286
  /** Unpack every part into one staging tree. */
190
287
  async function extractParts(
191
288
  manifest: Manifest,
192
289
  home: string,
193
290
  staging: string,
291
+ settings: KeySettings,
292
+ parts: readonly SnapshotPart[],
194
293
  ): Promise<void> {
195
294
  await rm(staging, { recursive: true, force: true });
196
- await mkdir(staging, { recursive: true });
197
- for (const part of manifest.parts) {
198
- const source = createReadStream(partPath(manifest.id, part.name, home));
199
- await extractTar(source.pipe(createDecompressor()), staging);
295
+ await mkdir(staging, { recursive: true, mode: 0o700 });
296
+ for (const part of parts) {
297
+ const source = await openPart(
298
+ partPath(manifest.id, part.name, home),
299
+ settings,
300
+ );
301
+ const archive = pipeline(source, createDecompressor(), () => {
302
+ /* a failure surfaces through extractTar's read */
303
+ });
304
+ await extractTar(archive, staging);
200
305
  }
201
306
  }
202
307
 
203
308
  // ── Applying ────────────────────────────────────────────────────────────────
204
309
 
310
+ /** An external root and where it lands on this machine. */
311
+ export type ExternalDestination = {
312
+ root: string;
313
+ dest: string;
314
+ sqlite?: boolean;
315
+ };
316
+
205
317
  /** Where an archive path lands on this machine. */
206
318
  export function destinationFor(
207
319
  archivePath: string,
208
320
  home: string,
209
321
  extras: readonly { n: number; source: string }[],
322
+ external: readonly ExternalDestination[] = [],
210
323
  ): string | null {
211
324
  const segments = archivePath.split("/");
212
325
  if (segments[0] === "extra") {
@@ -214,6 +327,15 @@ export function destinationFor(
214
327
  if (!extra) return null; // an extra path this machine has no mapping for
215
328
  return join(extra.source, ...segments.slice(2));
216
329
  }
330
+ const outside = external.find(
331
+ (entry) =>
332
+ archivePath === entry.root || archivePath.startsWith(`${entry.root}/`),
333
+ );
334
+ if (outside) {
335
+ const rest = archivePath.slice(outside.root.length).split("/");
336
+ return join(outside.dest, ...rest.filter(Boolean));
337
+ }
338
+ if (segments[0] === "sessions" || segments[0] === "plugin-src") return null;
217
339
  if (archivePath === DB_MEMBER) return join(home, "data", "talon.db");
218
340
  return join(home, ...segments);
219
341
  }
@@ -227,7 +349,9 @@ async function clearCovered(
227
349
  destRoot: string,
228
350
  archiveRoot: string,
229
351
  ): Promise<number> {
230
- const existing = await collectTree(destRoot, archiveRoot);
352
+ const existing = await collectTree(destRoot, archiveRoot, {
353
+ exclude: excludeForRoot(archiveRoot),
354
+ });
231
355
  let removed = 0;
232
356
  for (const entry of [...existing].reverse()) {
233
357
  try {
@@ -243,6 +367,21 @@ async function clearCovered(
243
367
  return removed;
244
368
  }
245
369
 
370
+ /**
371
+ * The roots to apply: the manifest's includes, plus the palace whenever a
372
+ * palace part is present (older manifests did not always list it).
373
+ */
374
+ function rootsToApply(manifest: Manifest): string[] {
375
+ const roots = manifest.includes.filter((root) => root !== DB_MEMBER);
376
+ const hasPalace = manifest.parts.some((part) =>
377
+ part.name.startsWith("palace-"),
378
+ );
379
+ if (hasPalace && !roots.includes("workspace/palace")) {
380
+ roots.push("workspace/palace");
381
+ }
382
+ return roots;
383
+ }
384
+
246
385
  /** Move one staged file into place, falling back to a copy across devices. */
247
386
  async function placeFile(from: string, to: string): Promise<void> {
248
387
  await mkdir(dirname(to), { recursive: true });
@@ -262,6 +401,7 @@ async function applyStaged(
262
401
  manifest: Manifest,
263
402
  staging: string,
264
403
  home: string,
404
+ external: readonly ExternalDestination[],
265
405
  ): Promise<RestoreReport> {
266
406
  const extras = manifest.extras ?? [];
267
407
  const report: RestoreReport = {
@@ -270,27 +410,33 @@ async function applyStaged(
270
410
  removed: 0,
271
411
  databaseReplaced: false,
272
412
  };
273
- for (const root of manifest.includes) {
274
- if (root === DB_MEMBER) continue;
413
+ for (const root of rootsToApply(manifest)) {
275
414
  const stagedRoot = join(staging, ...root.split("/"));
276
- const staged = await collectTree(stagedRoot, root);
415
+ const staged = await collectTree(stagedRoot, root, {
416
+ exclude: excludeForRoot(root),
417
+ });
277
418
  if (staged.length === 0) continue;
278
- const destRoot = destinationFor(root, home, extras);
419
+ const destRoot = destinationFor(root, home, extras, external);
279
420
  if (!destRoot) {
280
421
  logWarn("backup", `No destination for ${root} on this machine — skipped`);
281
422
  continue;
282
423
  }
283
424
  report.removed += await clearCovered(destRoot, root);
425
+ // A session database's sidecars describe the file being replaced.
426
+ if (external.some((entry) => entry.root === root && entry.sqlite)) {
427
+ await rm(`${destRoot}-wal`, { force: true });
428
+ await rm(`${destRoot}-shm`, { force: true });
429
+ }
284
430
  let written = 0;
285
431
  for (const entry of staged) {
286
- const dest = destinationFor(entry.archivePath, home, extras);
432
+ const dest = destinationFor(entry.archivePath, home, extras, external);
287
433
  if (!dest) continue;
288
- if (entry.type === "dir")
289
- await mkdir(dest, { recursive: true, mode: entry.mode });
434
+ if (entry.type === "dir") await mkdir(dest, { recursive: true });
290
435
  else {
291
436
  await placeFile(entry.source, dest);
292
437
  written += 1;
293
438
  }
439
+ await makePrivate(dest, entry.type, entry.mode);
294
440
  }
295
441
  report.written[root] = written;
296
442
  }
@@ -304,6 +450,7 @@ async function applyStaged(
304
450
  await rm(`${dbPath}-wal`, { force: true });
305
451
  await rm(`${dbPath}-shm`, { force: true });
306
452
  await placeFile(stagedDb, dbPath);
453
+ await makePrivate(dbPath, "file", 0o600);
307
454
  report.databaseReplaced = true;
308
455
  } catch {
309
456
  logWarn(
@@ -330,8 +477,49 @@ export type RestoreOptions = {
330
477
  beforeApply?: () => void | Promise<void>;
331
478
  /** Skip the automatic pre-restore checkpoint (it has already been taken). */
332
479
  skipCheckpoint?: boolean;
480
+ /** Restore a manifest that carries no signature (see restore-guard.ts). */
481
+ allowUnauthenticated?: ManifestTrust["allowUnauthenticated"];
482
+ /**
483
+ * Restoring onto a different machine: relocate the session stores and
484
+ * plugin checkouts to this user's home and rewrite config.json's paths
485
+ * (see sources/relocate.ts). Without it, a snapshot from another home
486
+ * is refused rather than written to paths this machine does not own.
487
+ */
488
+ clone?: boolean;
489
+ /** This machine's user home; defaults to `os.homedir()`. */
490
+ userHome?: string;
491
+ /** Environment for locating stores (CLAUDE_CONFIG_DIR, …). */
492
+ env?: Readonly<Record<string, string | undefined>>;
333
493
  };
334
494
 
495
+ /**
496
+ * Where each external root of `manifest` goes. A plain restore puts it
497
+ * back where it came from; a clone relocates it to this machine.
498
+ */
499
+ function externalDestinations(
500
+ manifest: Manifest,
501
+ clone: boolean,
502
+ target: CloneTarget,
503
+ ): ExternalDestination[] {
504
+ const origin = manifest.origin;
505
+ const foreign =
506
+ origin !== undefined &&
507
+ (origin.userHome !== target.userHome || origin.home !== target.home);
508
+ if (foreign && !clone && (manifest.external ?? []).length > 0) {
509
+ throw new TalonError(
510
+ `Snapshot ${manifest.id} was taken for ${origin.home} (user home ${origin.userHome}); ` +
511
+ `this machine is ${target.home}. Restore it with --clone to relocate its ` +
512
+ `session stores and plugin paths.`,
513
+ { reason: "bad_request" },
514
+ );
515
+ }
516
+ return (manifest.external ?? []).map((root) => ({
517
+ root: root.root,
518
+ dest: clone && origin ? relocateRoot(root, origin, target) : root.source,
519
+ ...(root.sqlite ? { sqlite: true } : {}),
520
+ }));
521
+ }
522
+
335
523
  /**
336
524
  * Restore a snapshot over this home directory. The daemon must already be
337
525
  * stopped — this does not check, because the two callers check in their
@@ -347,8 +535,29 @@ export async function restoreSnapshot(
347
535
  reason: "bad_request",
348
536
  });
349
537
  }
350
- await ensureParts(manifest, home, options.target);
351
- await verifyParts(manifest, home);
538
+ // Same default rule as the builder: the real home looks at the real
539
+ // user home; a caller that points at another Talon home (a test) must
540
+ // say which user home goes with it.
541
+ const realHome = options.home === undefined;
542
+ const cloneTarget: CloneTarget = {
543
+ home,
544
+ userHome: options.userHome ?? homedir(),
545
+ env: options.env ?? (realHome ? process.env : {}),
546
+ };
547
+ const missing = await missingParts(manifest, home);
548
+ await authenticateManifest(manifest, options.settings, {
549
+ allowUnauthenticated: options.allowUnauthenticated,
550
+ fromRemote: options.target !== undefined && missing.length > 0,
551
+ });
552
+ // Decided before anything is fetched or replaced: a refused clone must
553
+ // leave this machine exactly as it was.
554
+ const external = externalDestinations(
555
+ manifest,
556
+ options.clone ?? false,
557
+ cloneTarget,
558
+ );
559
+ const parts = await ensureParts(manifest, home, missing, options.target);
560
+ await verifyParts(manifest, home, options.settings, parts);
352
561
 
353
562
  let checkpointId: string | undefined;
354
563
  if (!options.skipCheckpoint) {
@@ -358,16 +567,24 @@ export async function restoreSnapshot(
358
567
  pinned: true,
359
568
  settings: options.settings,
360
569
  home,
570
+ userHome: options.userHome ?? (realHome ? homedir() : null),
571
+ env: cloneTarget.env,
361
572
  });
362
573
  checkpointId = checkpoint.id;
363
574
  log("backup", `Pre-restore checkpoint ${checkpointId} taken`);
364
575
  }
365
576
 
366
577
  const staging = join(snapshotDir(manifest.id, home), "restore-staging");
367
- await extractParts(manifest, home, staging);
578
+ await extractParts(manifest, home, staging, options.settings, parts);
368
579
  await options.beforeApply?.();
369
- const report = await applyStaged(manifest, staging, home);
580
+ const report = await applyStaged(manifest, staging, home, external);
370
581
  report.checkpointId = checkpointId;
582
+ if (options.clone && manifest.origin) {
583
+ report.configRewritten = await rewriteConfigForClone(
584
+ manifest.origin,
585
+ cloneTarget,
586
+ );
587
+ }
371
588
  await rm(staging, { recursive: true, force: true });
372
589
  log(
373
590
  "backup",