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
package/src/core/backup/plan.ts
CHANGED
|
@@ -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
|
-
"
|
|
74
|
-
"
|
|
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
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
198
|
-
const source =
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
351
|
-
|
|
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",
|