@chibitek/chibi 0.1.172 → 0.1.174
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/dist/assets/{abnfDiagram-O67JEVCF-CGoW52MW.js → abnfDiagram-O67JEVCF-BVDndX1g.js} +1 -1
- package/dist/assets/{arc-DEkKLw4F.js → arc-WduS6wYB.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-Cyi6MYs6.js → architectureDiagram-NJMV4G6O-CYK9LkCf.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CddA0wa_.js → blockDiagram-BEXU5L5S-BMj-aPW9.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Dg8q9n90.js → c4Diagram-YGBWAQC7-cXYIStgL.js} +1 -1
- package/dist/assets/channel-TratrwmM.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BSMub34c.js → chunk-2Q5K7J3B-CZ_nCppA.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW--Xc7Up5y.js → chunk-3FUC2YCW-C3wRAbtM.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-B9Ae4f30.js → chunk-5VM5RSS4-CMvgOXJP.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-Dh1To9G3.js → chunk-GTNCS2PH-9wBuLQzU.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-C_5eiV2n.js → chunk-GWA4HPMP-BpJMmfAi.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-5wQuPsks.js → chunk-JWPE2WC7-BnM06ABv.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-BGXS05ur.js → chunk-NETBCI7D-CjjiiXiT.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-ClIMhmD5.js → chunk-WEXAMYUT-D3heHHZs.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CcKs11-L.js → chunk-XXDRQBXY-n_P41ZlM.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BCiLckGU.js → classDiagram-v2-NBCMYWYE-Dz60Ek-6.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-JyAqT-ND.js → cose-bilkent-JH36ORCC-BCt6vBi9.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-BznovZj0.js → cynefin-OW5HDTMX-U0uq4EAQ.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BDRrTIEB.js → cynefinDiagram-VND7K2PF-CaQX0eUJ.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BbWguhLj.js → dagre-6A5THRUB-DlCMESYH.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BMk1KTBr.js → diagram-22UHCM2B-Cl1xmlTD.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-ChnEP2in.js → diagram-3UASUU5V-Cus3CPg7.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-C78imnTV.js → diagram-ATOU4E4O-i06rMvae.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-Dekx02f_.js → diagram-CDSNMT55-CUM5_heN.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-D2__fPwe.js → diagram-MLGK6HIB-B5NKRruo.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-C9l7MZm7.js → diagram-MPIPVDR6-CAumAcGl.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-D4x0h3XT.js → ebnfDiagram-ZINNZB2B-B19d7d8l.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-twChz5io.js → elk-276RUBZZ-NJEpfRWi.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BXEKWWMq.js → erDiagram-OPXOYQCR-BExk6CHc.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Ct-XrJMr.js → flowDiagram-KWPJA3E3-ClExQ_p0.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-Bn92lxCs.js → ganttDiagram-FUAMR5RP-1MeM9sSl.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dvfjo7cn.js → gitGraphDiagram-X574FWY7-Tw9Dudpa.js} +1 -1
- package/dist/assets/{index-DvneZE6Z.js → index-KYHldywn.js} +140 -140
- package/dist/assets/{index-CarzpTfN.js → index-qsorLr2x.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-MJ14OyNX.js → infoDiagram-VRGFBTTK-ChU2tjvl.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-nf9xhTNX.js → ishikawaDiagram-OU5B5YK6-BeTbrX2Q.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DqfK6X2e.js → journeyDiagram-ZHPQQLJL-B7ijaWBD.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-CjUR1QYT.js → kanban-definition-PNTS6WVX-JBmK2F1r.js} +1 -1
- package/dist/assets/{linear-dPqGsZwL.js → linear-B7FXe3Ue.js} +1 -1
- package/dist/assets/{mermaid.core-zL0DLPAI.js → mermaid.core-BPlfZpqv.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-C1-Jqg3_.js → mindmap-definition-NLK3R4M7-CmT3S5QR.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CFlvuAHC.js → pegDiagram-GJSIUBJH-CFFHIcdQ.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-BvyT0IgF.js → pieDiagram-5QR66LMP-yTiPmXRV.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-bg7JeR2c.js → quadrantDiagram-O4NWA36T-Cw8u7esm.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-dF2Nqim8.js → railroadDiagram-XR7U4H2S-BfKggsOW.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CwVu_Or6.js → requirementDiagram-PLB6GJNP-Cf3n79jF.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-wBC3-mRy.js → sankeyDiagram-IPEJSGJF-B2NkSZig.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-Bs9lHMf-.js → sequenceDiagram-PO4LG4MO-BfFYiYK3.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-d6YiGOIQ.js → sizeCapture-INFHLROL-CZ_2nC3f.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK--f6dscuE.js → stateDiagram-v2-GCMORJYK-B9jwzMXf.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CQpzBoAc.js → swimlanes-2SLR337P-Bxhafu77.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-DWNGCaH3.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-DpoyUyqt.js → timeline-definition-EJHVYXUP-Dle5SJls.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-Dg7mR3GW.js → usecaseDiagram-POWQR4AR-CgRTu9EO.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-CoEfTL7k.js → vennDiagram-UO4OBE2U-D7m_1z6B.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-24tIY01C.js → wardleyDiagram-VNRHLVJA-7-TzmX4L.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-CsoqIRqn.js → xychartDiagram-PMCCYNJV-BVXXdviS.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/index.js +2797 -1875
- package/dist-server/openmausbot.js +64 -2
- package/dist-server/pair-cli.js +64 -2
- package/dist-server/server/builtin-chibi.js +1 -1
- package/dist-server/server/dictation.js +8 -0
- package/dist-server/server/drivers/claude.js +4 -4
- package/dist-server/server/engine-tiers-view.js +49 -0
- package/dist-server/server/engine-tiers.js +37 -55
- package/dist-server/server/harness-import/engine-policy.js +6 -1
- package/dist-server/server/index.js +12 -13
- package/dist-server/server/oauth-account-core.js +153 -0
- package/dist-server/server/oauth-account-tools.js +7 -348
- package/dist-server/server/oauth-calendar-tools.js +551 -0
- package/dist-server/server/personal-bot.js +0 -26
- package/dist-server/server/remote-desktop.js +139 -7
- package/dist-server/server/routes/engine-tiers.js +12 -1
- package/dist-server/server/subscription-bots.js +2 -21
- package/dist-server/server/turn-resources.js +28 -0
- package/dist-server/server/workspace-backup-archive.js +138 -0
- package/dist-server/server/workspace-backup-http.js +50 -8
- package/dist-server/server/workspace-backup-private.js +83 -0
- package/dist-server/server/workspace-backup-secrets.js +90 -0
- package/dist-server/server/workspace-backup.js +171 -235
- package/dist-server/server/workspace-restore-paths.js +186 -0
- package/dist-server/shared/oauth-providers.js +2 -1
- package/dist-server/shared/provider-accounts.js +29 -0
- package/package.json +1 -1
- package/dist/assets/channel-Dda8O6gd.js +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C1ko4o6t.js +0 -8
|
@@ -1,24 +1,26 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import {
|
|
1
|
+
// A filesystem snapshot, not an additive import, encrypted unless the person
|
|
2
|
+
// turns that off (workspace-backup-archive.ts). The caller must quiesce
|
|
3
|
+
// writers before create/commit. Restore runs before config/store load.
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { chmodSync, closeSync, constants, copyFileSync, cpSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
|
|
5
6
|
import { isAbsolute, join, parse, posix, relative, resolve, win32 } from "node:path";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { backup, DatabaseSync } from "node:sqlite";
|
|
8
|
-
import { pipeline } from "node:stream/promises";
|
|
9
9
|
import * as tar from "tar";
|
|
10
10
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
11
11
|
import { writeFileAtomic } from "./atomic.js";
|
|
12
12
|
import { escapeAttribute, splitTranscriptAttachments } from "../src/lib/composer-attachments.js";
|
|
13
13
|
import { WORKSPACE_BACKUP_CLIENT_KEYS } from "../shared/workspace-backup-client.js";
|
|
14
14
|
import { ephemeralWorkspaceTokenPath, excludedWorkspaceAuthPath, portableWorkspaceConfig, restoredWorkspaceConfig } from "./workspace-backup-policy.js";
|
|
15
|
-
|
|
15
|
+
import { privateBotIds, privateBotPath, scrubPrivateMessages } from "./workspace-backup-private.js";
|
|
16
|
+
import { MAX_WORKSPACE_BACKUP_BYTES, passwordKey, readArchive, writeArchive } from "./workspace-backup-archive.js";
|
|
17
|
+
import { SECRET_TOP_LEVEL, secretWorkspacePath, validSecretsBundle } from "./workspace-backup-secrets.js";
|
|
18
|
+
import { absolutePath, rebaseFields, rebasePath, rebaseTree, reclonableCheckout, restoreLinks, restorePathPairs, validRelative, } from "./workspace-restore-paths.js";
|
|
19
|
+
export { MAX_WORKSPACE_BACKUP_BYTES, MAX_WORKSPACE_BACKUP_UPLOAD_BYTES } from "./workspace-backup-archive.js";
|
|
20
|
+
export { privateBotIds, privateBotPath } from "./workspace-backup-private.js";
|
|
16
21
|
export const MAX_WORKSPACE_BACKUP_FILES = 100_000;
|
|
17
|
-
export const MAX_WORKSPACE_BACKUP_UPLOAD_BYTES = MAX_WORKSPACE_BACKUP_BYTES + 256 * 1024 ** 2;
|
|
18
22
|
const MAX_METADATA_BYTES = 16 * 1024 ** 2;
|
|
19
|
-
const
|
|
20
|
-
const HEADER_BYTES = MAGIC.length + 16 + 12;
|
|
21
|
-
const TAG_BYTES = 16;
|
|
23
|
+
const MAX_REPORTED_PATHS = 200;
|
|
22
24
|
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
23
25
|
const EXCLUDED = new Set([
|
|
24
26
|
".backups", "tools", "cache", ".cache", "tmp", ".tmp", "dist-native", "tunnel-runtime",
|
|
@@ -33,102 +35,38 @@ const REINSTALLABLE_DIRS = new Set(["node_modules", ".pnpm-store", ".venv", "ven
|
|
|
33
35
|
/** Links a running browser creates to mark its profile in use; they point
|
|
34
36
|
* at a process, not at data, and a restored profile must not carry them. */
|
|
35
37
|
const BROWSER_LOCK_LINKS = new Set(["lock", ".parentlock", "SingletonLock", "SingletonSocket", "SingletonCookie"]);
|
|
38
|
+
const SECRETS_NOTE = "Saved credentials, provider and MCP connections, managed provider login homes and browser login profiles are not transferred. Destination connections are preserved; reconnect on a new device.";
|
|
36
39
|
const EXCLUSION_NOTES = [
|
|
37
40
|
"Device pairing, server identity, live leases and runtime files (existing destination identities are preserved).",
|
|
38
|
-
|
|
41
|
+
SECRETS_NOTE,
|
|
39
42
|
"Downloaded tools and caches; these can be installed again.",
|
|
40
43
|
"Project dependency and build folders (node_modules, .venv, __pycache__, .next, .turbo, .build, DerivedData); reinstall them in each project after restoring.",
|
|
41
44
|
"External project folders, CLI login homes, browser session homes, OS keychains, companion devices and other servers.",
|
|
42
45
|
"VM/container disk layers and remote cloud data; durable files inside this workspace are included.",
|
|
43
46
|
];
|
|
44
47
|
const RESTORE_WARNING = "Restoring pauses routines, webhooks and calendar calls, and does not resume pending delegations, queued chat messages or queued runs. Definitions and historical receipts are retained; review before enabling them again.";
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const threadIds = new Set();
|
|
49
|
-
const path = join(dataDir, "bots.json");
|
|
50
|
-
if (!existsSync(path))
|
|
51
|
-
return { botIds, threadIds };
|
|
52
|
-
let value;
|
|
53
|
-
try {
|
|
54
|
-
value = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return { botIds, threadIds };
|
|
58
|
-
}
|
|
59
|
-
if (!Array.isArray(value))
|
|
60
|
-
return { botIds, threadIds };
|
|
61
|
-
for (const bot of value) {
|
|
62
|
-
if (!record(bot) || bot.personal !== true || typeof bot.id !== "string")
|
|
63
|
-
continue;
|
|
64
|
-
botIds.add(bot.id);
|
|
65
|
-
if (typeof bot.threadId === "string")
|
|
66
|
-
threadIds.add(bot.threadId);
|
|
67
|
-
if (Array.isArray(bot.tasks))
|
|
68
|
-
for (const task of bot.tasks)
|
|
69
|
-
if (record(task) && typeof task.threadId === "string")
|
|
70
|
-
threadIds.add(task.threadId);
|
|
71
|
-
}
|
|
72
|
-
return { botIds, threadIds };
|
|
73
|
-
}
|
|
74
|
-
/** Whether a data-folder path belongs to the Private Bot: a folder or file
|
|
75
|
-
* named after its id or one of its threads, anywhere below the top level
|
|
76
|
-
* (workspaces/<id>, task-workspaces/<id>, events/<thread>.jsonl, ...). */
|
|
77
|
-
export function privateBotPath(path, ids) {
|
|
78
|
-
if (ids.size === 0)
|
|
79
|
-
return false;
|
|
80
|
-
const parts = path.split("/");
|
|
81
|
-
return parts.slice(1).some((part) => {
|
|
82
|
-
const stem = part.split(".")[0] ?? part;
|
|
83
|
-
return ids.has(part) || ids.has(stem);
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
/** Remove the Private Bot's rows from a copy of the message database, and
|
|
87
|
-
* return the attachment names its messages referenced. */
|
|
88
|
-
function scrubPrivateMessages(dbPath, botIds, threadIds) {
|
|
89
|
-
const attachments = new Set();
|
|
90
|
-
if (!existsSync(dbPath) || (botIds.size === 0 && threadIds.size === 0))
|
|
91
|
-
return attachments;
|
|
92
|
-
const db = new DatabaseSync(dbPath);
|
|
93
|
-
try {
|
|
94
|
-
const threads = [...threadIds];
|
|
95
|
-
const bots = [...botIds];
|
|
96
|
-
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => String(row.name)));
|
|
97
|
-
const marks = (count) => Array.from({ length: count }, () => "?").join(", ");
|
|
98
|
-
if (threads.length && tables.has("messages")) {
|
|
99
|
-
for (const row of db.prepare(`SELECT json FROM messages WHERE thread_id IN (${marks(threads.length)})`).all(...threads)) {
|
|
100
|
-
for (const match of String(row.json).matchAll(/\/api\/attachments\/([A-Za-z0-9._-]{1,200})/g))
|
|
101
|
-
attachments.add(match[1]);
|
|
102
|
-
}
|
|
103
|
-
db.prepare(`DELETE FROM messages WHERE thread_id IN (${marks(threads.length)})`).run(...threads);
|
|
104
|
-
}
|
|
105
|
-
if (threads.length && tables.has("thread_state"))
|
|
106
|
-
db.prepare(`DELETE FROM thread_state WHERE thread_id IN (${marks(threads.length)})`).run(...threads);
|
|
107
|
-
if (tables.has("chat_followups") && (threads.length || bots.length)) {
|
|
108
|
-
db.prepare(`DELETE FROM chat_followups WHERE thread_id IN (${marks(threads.length || 1)}) OR owner_id IN (${marks(bots.length || 1)})`)
|
|
109
|
-
.run(...(threads.length ? threads : [""]), ...(bots.length ? bots : [""]));
|
|
110
|
-
}
|
|
111
|
-
if (bots.length && tables.has("memory_files"))
|
|
112
|
-
db.prepare(`DELETE FROM memory_files WHERE bot_id IN (${marks(bots.length)})`).run(...bots);
|
|
113
|
-
db.exec("VACUUM");
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
db.close();
|
|
117
|
-
}
|
|
118
|
-
return attachments;
|
|
119
|
-
}
|
|
48
|
+
/** Encrypted backups can carry everything a new computer needs to sign in
|
|
49
|
+
* again; these still cannot move, because they are sealed to this one. */
|
|
50
|
+
const SECRETS_KEPT_NOTE = "Phone pairing, companion devices, the Chibitek sign-in, browser login profiles and command-line logins kept in this computer's keychain stay on this computer; sign in to those again after restoring.";
|
|
120
51
|
function excluded(name) {
|
|
121
52
|
return EXCLUDED.has(name) || excludedWorkspaceAuthPath(name) || name.startsWith("openmausbot-server.lease.") || name.startsWith("box-create-requests.lock.") || /^perm-[A-Za-z0-9_-]+\.sock$/.test(name);
|
|
122
53
|
}
|
|
123
|
-
function forbiddenArchivePath(path) {
|
|
54
|
+
function forbiddenArchivePath(path, secrets = false) {
|
|
124
55
|
const folded = path.toLowerCase();
|
|
56
|
+
// Inside a canonical secret root, names keep their case (provider project
|
|
57
|
+
// folders are named after paths like /Users/...); only the vault key file,
|
|
58
|
+
// which each computer keeps for itself, may not arrive under another case.
|
|
59
|
+
if (secrets && secretWorkspacePath(path) && folded !== "providers/native-vault/key.bin")
|
|
60
|
+
return false;
|
|
125
61
|
return excluded(folded.split("/")[0]) || excludedWorkspaceAuthPath(folded) ||
|
|
126
62
|
(path !== folded && ["config.json", "webhooks.json"].includes(folded));
|
|
127
63
|
}
|
|
128
|
-
function preserved(name) {
|
|
64
|
+
function preserved(name, secrets = false) {
|
|
129
65
|
// Existing WAL/SHM belong to the old database and must move into safety,
|
|
130
66
|
// never accompany a different restored main database.
|
|
131
67
|
const folded = name.toLowerCase();
|
|
68
|
+
if (secrets && SECRET_TOP_LEVEL.has(name))
|
|
69
|
+
return false;
|
|
132
70
|
return excluded(folded) && folded !== "messages.db-wal" && folded !== "messages.db-shm";
|
|
133
71
|
}
|
|
134
72
|
function folder(path) {
|
|
@@ -166,11 +104,6 @@ function newJob(dataDir) {
|
|
|
166
104
|
mkdirSync(directory, { mode: 0o700 });
|
|
167
105
|
return { id, directory };
|
|
168
106
|
}
|
|
169
|
-
function validRelative(path) {
|
|
170
|
-
return !!path && path.length <= 4096 && !isAbsolute(path) && !/[\\\0:]/.test(path) &&
|
|
171
|
-
path.split("/").every((part) => !!part && part !== "." && part !== ".." && !/[. ]$/.test(part) &&
|
|
172
|
-
!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));
|
|
173
|
-
}
|
|
174
107
|
function record(value) {
|
|
175
108
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
176
109
|
}
|
|
@@ -240,19 +173,6 @@ function assertLocalAuthOutsideSnapshot(dataDir) {
|
|
|
240
173
|
check(instance.config.configDir, "config.configDir", String(environment.HOME || environment.USERPROFILE || homedir()));
|
|
241
174
|
}
|
|
242
175
|
}
|
|
243
|
-
function passwordKey(password, salt) {
|
|
244
|
-
if (typeof password !== "string" || password.length < 12 || Buffer.byteLength(password) > 1024) {
|
|
245
|
-
throw new Error("Use a backup password of at least 12 characters (at most 1024 bytes).");
|
|
246
|
-
}
|
|
247
|
-
return new Promise((resolveKey, reject) => {
|
|
248
|
-
scrypt(password, salt, 32, { N: 131_072, r: 8, p: 1, maxmem: 256 * 1024 ** 2 }, (error, key) => {
|
|
249
|
-
if (error)
|
|
250
|
-
reject(error);
|
|
251
|
-
else
|
|
252
|
-
resolveKey(key);
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
176
|
// Open without following links and copy synchronously so this server cannot
|
|
257
177
|
// interleave a mutation. External writers are detected where stat permits.
|
|
258
178
|
function copyRegular(source, destination) {
|
|
@@ -261,7 +181,7 @@ function copyRegular(source, destination) {
|
|
|
261
181
|
try {
|
|
262
182
|
const before = fstatSync(input);
|
|
263
183
|
if (!before.isFile() || before.size > MAX_WORKSPACE_BACKUP_BYTES)
|
|
264
|
-
throw new Error("A workspace file is not regular or exceeds the
|
|
184
|
+
throw new Error("A workspace file is not regular or exceeds the 64 GB backup limit.");
|
|
265
185
|
const mode = before.mode & 0o100 ? 0o700 : 0o600;
|
|
266
186
|
output = openSync(destination, "wx", mode);
|
|
267
187
|
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
@@ -351,16 +271,27 @@ function databaseCounts(path) {
|
|
|
351
271
|
export async function createWorkspaceBackup(dataDir, options) {
|
|
352
272
|
if (Object.hasOwn(options, "credentials"))
|
|
353
273
|
throw new Error("Workspace backups do not transfer credentials.");
|
|
274
|
+
const encrypted = options.password !== undefined;
|
|
275
|
+
const secrets = options.includeSecrets === true;
|
|
276
|
+
if (secrets && !encrypted)
|
|
277
|
+
throw new Error("Keys, passwords and tokens are only included in an encrypted backup. Set a backup password.");
|
|
278
|
+
if (options.secrets && (!secrets || !validSecretsBundle(options.secrets)))
|
|
279
|
+
throw new Error("Invalid secrets for this workspace backup.");
|
|
354
280
|
assertLocalAuthOutsideSnapshot(dataDir);
|
|
355
281
|
const salt = randomBytes(16);
|
|
356
|
-
const key = await passwordKey(options.password, salt);
|
|
282
|
+
const key = encrypted ? await passwordKey(options.password, salt) : null;
|
|
357
283
|
const job = newJob(dataDir);
|
|
358
284
|
const snapshot = join(job.directory, "snapshot");
|
|
359
285
|
folder(join(snapshot, "data"));
|
|
360
286
|
const entries = [];
|
|
361
|
-
const
|
|
287
|
+
const links = [];
|
|
288
|
+
const checkouts = [];
|
|
289
|
+
const warnings = ["Stop external editors and managed desktops before exporting; files written outside this server cannot be frozen by the backup gate.",
|
|
290
|
+
encrypted ? "Conversation text and user files are not redacted and may contain secrets you pasted. Keep the encrypted backup private." : "This backup is not encrypted. Anyone with the file can read your conversations and files; keep it private.",
|
|
291
|
+
...(secrets ? ["This backup holds your keys, passwords and tokens. Anyone with the file and its password can use them."] : []), RESTORE_WARNING];
|
|
362
292
|
let bytes = 0;
|
|
363
293
|
let skippedLinks = 0;
|
|
294
|
+
const progress = () => options.onProgress?.({ phase: "copying", files: entries.length, bytes });
|
|
364
295
|
try {
|
|
365
296
|
const root = realpathSync(dataDir);
|
|
366
297
|
const sourceDb = join(root, "messages.db");
|
|
@@ -399,7 +330,7 @@ export async function createWorkspaceBackup(dataDir, options) {
|
|
|
399
330
|
warnings.push("Your Private Bot is not in this backup: its conversations, memory and files stay only on this computer.");
|
|
400
331
|
const walk = (directory, prefix = "") => {
|
|
401
332
|
for (const name of readdirSync(directory).sort()) {
|
|
402
|
-
if (!prefix && excluded(name))
|
|
333
|
+
if (!prefix && excluded(name) && !(secrets && SECRET_TOP_LEVEL.has(name)))
|
|
403
334
|
continue;
|
|
404
335
|
const path = prefix ? `${prefix}/${name}` : name;
|
|
405
336
|
if (privateBotPath(path, hiddenIds) || (prefix === "attachments" && hiddenAttachments.has(name)))
|
|
@@ -411,11 +342,12 @@ export async function createWorkspaceBackup(dataDir, options) {
|
|
|
411
342
|
// refills itself, so the company's copy leaves it out.
|
|
412
343
|
if (options.excludePrivateBot && !prefix && name === "nexus-outbox")
|
|
413
344
|
continue;
|
|
414
|
-
|
|
345
|
+
const secret = secrets && secretWorkspacePath(path);
|
|
346
|
+
if ((excludedWorkspaceAuthPath(path) && !secret) || ephemeralWorkspaceTokenPath(path) || path === "providers/native-vault/key.bin")
|
|
415
347
|
continue;
|
|
416
348
|
// Do not silently skip noncanonical source spellings: reject them so
|
|
417
349
|
// a case-sensitive host cannot export auth paths active on Windows/Mac.
|
|
418
|
-
if (forbiddenArchivePath(path))
|
|
350
|
+
if (forbiddenArchivePath(path, secrets))
|
|
419
351
|
throw new Error("A workspace filename conflicts with a protected authentication or runtime path.");
|
|
420
352
|
if (!validRelative(path))
|
|
421
353
|
throw new Error("A workspace filename cannot be safely restored on supported platforms.");
|
|
@@ -426,29 +358,25 @@ export async function createWorkspaceBackup(dataDir, options) {
|
|
|
426
358
|
if (stat.isSymbolicLink() && BROWSER_LOCK_LINKS.has(name))
|
|
427
359
|
continue;
|
|
428
360
|
if (stat.isSymbolicLink()) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
catch {
|
|
434
|
-
throw new Error(`Cannot back up dangling symbolic link: ${path}`);
|
|
435
|
-
}
|
|
436
|
-
const rel = relative(root, target);
|
|
437
|
-
if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
|
|
438
|
-
throw new Error(`Cannot back up symbolic link outside the workspace: ${path}`);
|
|
361
|
+
// The app's disposable native-discovery links are recreated by the app.
|
|
362
|
+
if (/^workspaces\/[^/]+\/\.(?:claude|agents|grok)\/skills\/[^/]+$/.test(path)) {
|
|
363
|
+
skippedLinks++;
|
|
364
|
+
continue;
|
|
439
365
|
}
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
!rel.replaceAll("\\", "/").startsWith(`workspaces/${path.split("/")[1]}/skills/`)) {
|
|
444
|
-
throw new Error(`Cannot back up user-created symbolic link; replace it with regular files first: ${path}`);
|
|
445
|
-
}
|
|
446
|
-
skippedLinks++;
|
|
366
|
+
// Any other link is recorded, never followed: the restore remakes
|
|
367
|
+
// it with its target rebased to the new computer (restoreLinks).
|
|
368
|
+
links.push({ path, target: readlinkSync(source) });
|
|
447
369
|
continue;
|
|
448
370
|
}
|
|
449
|
-
if (entries.length >= MAX_WORKSPACE_BACKUP_FILES)
|
|
371
|
+
if (entries.length + links.length >= MAX_WORKSPACE_BACKUP_FILES)
|
|
450
372
|
throw new Error("The workspace exceeds the 100,000-entry backup limit.");
|
|
451
373
|
const destination = join(snapshot, "data", path);
|
|
374
|
+
// A clean checkout that origin already holds is cloned again, not copied.
|
|
375
|
+
const remote = stat.isDirectory() && prefix && !secret && entryExists(join(source, ".git")) ? reclonableCheckout(source) : null;
|
|
376
|
+
if (remote) {
|
|
377
|
+
checkouts.push({ path, remote });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
452
380
|
if (stat.isDirectory()) {
|
|
453
381
|
folder(destination);
|
|
454
382
|
entries.push({ path, type: "directory", size: 0, mode: 0o700 });
|
|
@@ -467,7 +395,7 @@ export async function createWorkspaceBackup(dataDir, options) {
|
|
|
467
395
|
writeFileSync(destination, kept.join("\n"), { mode: 0o600, flag: "wx" });
|
|
468
396
|
copied = { size: lstatSync(destination).size, mode: 0o600, sha256: hashFile(destination) };
|
|
469
397
|
}
|
|
470
|
-
else if (path === "config.json" || path === "webhooks.json") {
|
|
398
|
+
else if ((path === "config.json" || path === "webhooks.json") && !secrets) {
|
|
471
399
|
const value = privateJson(source);
|
|
472
400
|
if (path === "config.json")
|
|
473
401
|
writeJson(destination, portableWorkspaceConfig(value));
|
|
@@ -486,46 +414,43 @@ export async function createWorkspaceBackup(dataDir, options) {
|
|
|
486
414
|
copied = copyRegular(path === "messages.db" ? join(snapshot, "messages-snapshot.db") : source, destination);
|
|
487
415
|
bytes += copied.size;
|
|
488
416
|
if (bytes > MAX_WORKSPACE_BACKUP_BYTES)
|
|
489
|
-
throw new Error("The workspace exceeds the
|
|
417
|
+
throw new Error("The workspace exceeds the 64 GB backup limit.");
|
|
490
418
|
entries.push({ path, type: "file", ...copied });
|
|
419
|
+
if (entries.length % 500 === 0)
|
|
420
|
+
progress();
|
|
491
421
|
}
|
|
492
|
-
else
|
|
422
|
+
else if (!secret)
|
|
493
423
|
throw new Error(`Cannot back up special or live runtime file: ${path}`);
|
|
494
424
|
}
|
|
495
425
|
};
|
|
496
426
|
walk(root);
|
|
497
427
|
if (skippedLinks)
|
|
498
428
|
warnings.push(`${skippedLinks} managed skill discovery link(s) were omitted and are recreated by the app.`);
|
|
429
|
+
if (checkouts.length)
|
|
430
|
+
warnings.push(`${checkouts.length} project checkout(s) with no local changes were left out; clone them again from their remote. Files git ignores in them (such as .env) are not included.`);
|
|
431
|
+
if (secrets && !options.secrets)
|
|
432
|
+
warnings.push("This computer's secure credential store was not available, so connection keys saved there are not in this backup.");
|
|
499
433
|
const summary = {
|
|
500
434
|
format: "openmaus.workspace-backup", version: 1, id: job.id, createdAt: new Date().toISOString(),
|
|
501
435
|
appVersion: options.appVersion ?? "unknown", files: entries.filter((entry) => entry.type === "file").length,
|
|
502
436
|
directories: entries.filter((entry) => entry.type === "directory").length, bytes,
|
|
503
437
|
bots: countJsonArray(join(snapshot, "data", "bots.json")), groups: countJsonArray(join(snapshot, "data", "groups.json")),
|
|
504
438
|
...databaseCounts(join(snapshot, "data", "messages.db")),
|
|
505
|
-
exclusions: EXCLUSION_NOTES, warnings,
|
|
439
|
+
exclusions: secrets ? [SECRETS_KEPT_NOTE, ...EXCLUSION_NOTES.filter((note) => note !== SECRETS_NOTE)] : EXCLUSION_NOTES, warnings,
|
|
440
|
+
encrypted, secrets, checkouts,
|
|
506
441
|
};
|
|
507
442
|
const manifest = {
|
|
508
|
-
summary, sourceDataDir: resolve(dataDir), clientState: options.clientState ?? {}, entries,
|
|
443
|
+
summary, sourceDataDir: resolve(dataDir), sourceHome: homedir(), clientState: options.clientState ?? {}, links, entries,
|
|
509
444
|
};
|
|
510
445
|
writeJson(join(snapshot, "manifest.json"), manifest);
|
|
511
446
|
// Validate our own output too: JSON serialization must never silently
|
|
512
447
|
// discard metadata or produce a backup the importer cannot read.
|
|
513
448
|
validateManifest(privateJson(join(snapshot, "manifest.json")));
|
|
449
|
+
if (options.secrets)
|
|
450
|
+
writeJson(join(snapshot, "secrets.json"), options.secrets);
|
|
451
|
+
options.onProgress?.({ phase: "writing", files: entries.length, bytes });
|
|
514
452
|
const path = join(job.directory, "workspace.ombbackup");
|
|
515
|
-
|
|
516
|
-
const header = Buffer.concat([MAGIC, salt, iv]);
|
|
517
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
518
|
-
cipher.setAAD(header);
|
|
519
|
-
writeFileSync(path, header, { mode: 0o600, flag: "wx" });
|
|
520
|
-
await pipeline(tar.c({ cwd: snapshot, portable: true, strict: true }, ["manifest.json", "data"]), cipher, createWriteStream(path, { flags: "a", mode: 0o600 }));
|
|
521
|
-
const fd = openSync(path, "a");
|
|
522
|
-
try {
|
|
523
|
-
writeSync(fd, cipher.getAuthTag());
|
|
524
|
-
fsyncSync(fd);
|
|
525
|
-
}
|
|
526
|
-
finally {
|
|
527
|
-
closeSync(fd);
|
|
528
|
-
}
|
|
453
|
+
await writeArchive(path, snapshot, ["manifest.json", ...(options.secrets ? ["secrets.json"] : []), "data"], key ? { key, salt } : null);
|
|
529
454
|
rmSync(snapshot, { recursive: true, force: true });
|
|
530
455
|
return { id: job.id, path, summary };
|
|
531
456
|
}
|
|
@@ -534,7 +459,7 @@ export async function createWorkspaceBackup(dataDir, options) {
|
|
|
534
459
|
throw error;
|
|
535
460
|
}
|
|
536
461
|
finally {
|
|
537
|
-
key
|
|
462
|
+
key?.fill(0);
|
|
538
463
|
}
|
|
539
464
|
}
|
|
540
465
|
function validateManifest(value) {
|
|
@@ -545,8 +470,11 @@ function validateManifest(value) {
|
|
|
545
470
|
value.sourceDataDir.length > 4096 || value.sourceDataDir.includes("\0") ||
|
|
546
471
|
value.sourceDataDir === posix.parse(value.sourceDataDir).root || value.sourceDataDir === win32.parse(value.sourceDataDir).root ||
|
|
547
472
|
value.sourceDataDir.replaceAll("\\", "/").split("/").some((part) => part === "." || part === "..") ||
|
|
548
|
-
Object.keys(value).some((key) => !["summary", "sourceDataDir", "clientState", "entries"].includes(key)) ||
|
|
549
|
-
|
|
473
|
+
Object.keys(value).some((key) => !["summary", "sourceDataDir", "sourceHome", "clientState", "links", "entries"].includes(key)) ||
|
|
474
|
+
(value.sourceHome !== undefined && (typeof value.sourceHome !== "string" || !absolutePath(value.sourceHome) || value.sourceHome.length > 4096 || value.sourceHome.includes("\0"))) ||
|
|
475
|
+
Object.keys(value.summary).some((key) => !["format", "version", "id", "createdAt", "appVersion", "files", "directories", "bytes", "bots", "groups", "threads", "messages", "exclusions", "warnings", "encrypted", "secrets", "checkouts"].includes(key)) ||
|
|
476
|
+
!["undefined", "boolean"].includes(typeof value.summary.encrypted) || !["undefined", "boolean"].includes(typeof value.summary.secrets) ||
|
|
477
|
+
(value.summary.secrets === true && value.summary.encrypted === false) || !record(value.clientState) ||
|
|
550
478
|
!Object.values(value.clientState).every((item) => typeof item === "string") || !Array.isArray(value.entries) ||
|
|
551
479
|
value.entries.length > MAX_WORKSPACE_BACKUP_FILES)
|
|
552
480
|
throw new Error("Invalid or unsupported workspace backup metadata.");
|
|
@@ -561,11 +489,16 @@ function validateManifest(value) {
|
|
|
561
489
|
if (!Array.isArray(value.summary[key]) || !value.summary[key].every((item) => typeof item === "string"))
|
|
562
490
|
throw new Error("Invalid workspace backup summary.");
|
|
563
491
|
}
|
|
492
|
+
const checkouts = value.summary.checkouts ?? [];
|
|
493
|
+
if (!Array.isArray(checkouts) || !checkouts.every((item) => record(item) && Object.keys(item).length === 2 && typeof item.path === "string" && validRelative(item.path) &&
|
|
494
|
+
typeof item.remote === "string" && item.remote.length <= 4096))
|
|
495
|
+
throw new Error("Invalid workspace backup summary.");
|
|
496
|
+
const secrets = value.summary.secrets === true;
|
|
564
497
|
const names = new Map();
|
|
565
498
|
let bytes = 0;
|
|
566
499
|
let files = 0;
|
|
567
500
|
for (const raw of value.entries) {
|
|
568
|
-
if (!record(raw) || typeof raw.path !== "string" || !validRelative(raw.path) || forbiddenArchivePath(raw.path) ||
|
|
501
|
+
if (!record(raw) || typeof raw.path !== "string" || !validRelative(raw.path) || forbiddenArchivePath(raw.path, secrets) ||
|
|
569
502
|
Object.keys(raw).some((key) => !["path", "type", "size", "mode", "sha256"].includes(key)) ||
|
|
570
503
|
!["file", "directory"].includes(String(raw.type)) || !Number.isSafeInteger(raw.size) || raw.size < 0 ||
|
|
571
504
|
![0o600, 0o700].includes(raw.mode) || names.has(raw.path.toLowerCase()))
|
|
@@ -584,6 +517,15 @@ function validateManifest(value) {
|
|
|
584
517
|
names.size - files !== value.summary.directories) {
|
|
585
518
|
throw new Error("Workspace backup contents do not match their summary or exceed the limit.");
|
|
586
519
|
}
|
|
520
|
+
const links = value.links ?? [];
|
|
521
|
+
if (!Array.isArray(links) || links.length + names.size > MAX_WORKSPACE_BACKUP_FILES)
|
|
522
|
+
throw new Error("Invalid workspace backup links.");
|
|
523
|
+
for (const link of links) {
|
|
524
|
+
if (!record(link) || Object.keys(link).length !== 2 || typeof link.path !== "string" || !validRelative(link.path) || forbiddenArchivePath(link.path, secrets) ||
|
|
525
|
+
typeof link.target !== "string" || !link.target || link.target.length > 4096 || link.target.includes("\0") || names.has(link.path.toLowerCase()))
|
|
526
|
+
throw new Error("Unsafe or duplicate workspace backup link.");
|
|
527
|
+
names.set(link.path.toLowerCase(), "link");
|
|
528
|
+
}
|
|
587
529
|
for (const path of names.keys()) {
|
|
588
530
|
const parts = path.split("/");
|
|
589
531
|
for (let i = 1; i < parts.length; i++)
|
|
@@ -592,34 +534,6 @@ function validateManifest(value) {
|
|
|
592
534
|
}
|
|
593
535
|
return value;
|
|
594
536
|
}
|
|
595
|
-
async function decryptArchive(inputPath, plaintext, password) {
|
|
596
|
-
const fd = openSync(inputPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
597
|
-
let key;
|
|
598
|
-
try {
|
|
599
|
-
const stat = fstatSync(fd);
|
|
600
|
-
if (!stat.isFile() || stat.size < HEADER_BYTES + TAG_BYTES || stat.size > MAX_WORKSPACE_BACKUP_UPLOAD_BYTES)
|
|
601
|
-
throw new Error("Invalid or oversized workspace backup file.");
|
|
602
|
-
const header = Buffer.alloc(HEADER_BYTES);
|
|
603
|
-
const tag = Buffer.alloc(TAG_BYTES);
|
|
604
|
-
if (readSync(fd, header, 0, header.length, 0) !== header.length || !header.subarray(0, MAGIC.length).equals(MAGIC) ||
|
|
605
|
-
readSync(fd, tag, 0, tag.length, stat.size - TAG_BYTES) !== tag.length)
|
|
606
|
-
throw new Error("This is not a supported encrypted workspace backup.");
|
|
607
|
-
key = await passwordKey(password, header.subarray(MAGIC.length, MAGIC.length + 16));
|
|
608
|
-
const decipher = createDecipheriv("aes-256-gcm", key, header.subarray(MAGIC.length + 16));
|
|
609
|
-
decipher.setAAD(header);
|
|
610
|
-
decipher.setAuthTag(tag);
|
|
611
|
-
try {
|
|
612
|
-
await pipeline(createReadStream(inputPath, { fd, autoClose: false, start: HEADER_BYTES, end: stat.size - TAG_BYTES - 1 }), decipher, createWriteStream(plaintext, { flags: "wx", mode: 0o600 }));
|
|
613
|
-
}
|
|
614
|
-
catch {
|
|
615
|
-
throw new Error("The backup password is incorrect, or the backup file is damaged. Nothing was restored.");
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
finally {
|
|
619
|
-
key?.fill(0);
|
|
620
|
-
closeSync(fd);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
537
|
async function inspectTar(path) {
|
|
624
538
|
const fd = openSync(path, "r");
|
|
625
539
|
const signature = Buffer.alloc(2);
|
|
@@ -639,7 +553,7 @@ async function inspectTar(path) {
|
|
|
639
553
|
let problem = "";
|
|
640
554
|
await tar.t({ file: path, strict: true, onReadEntry(entry) {
|
|
641
555
|
const name = entry.type === "Directory" ? entry.path.replace(/\/$/, "") : entry.path;
|
|
642
|
-
const expectedRoot = name === "manifest.json" || name === "data" || name.startsWith("data/");
|
|
556
|
+
const expectedRoot = name === "manifest.json" || name === "secrets.json" || name === "data" || name.startsWith("data/");
|
|
643
557
|
// ReadEntry normalizes backslashes on Windows; inspect the raw header too
|
|
644
558
|
// so the same hostile archive is rejected before extraction on every OS.
|
|
645
559
|
if (entry.header.path?.includes("\\") || !validRelative(name) || !expectedRoot || !["File", "Directory"].includes(entry.type) ||
|
|
@@ -648,7 +562,7 @@ async function inspectTar(path) {
|
|
|
648
562
|
problem ||= "The archive contains unsafe, duplicate or unsupported entries.";
|
|
649
563
|
return;
|
|
650
564
|
}
|
|
651
|
-
if (name === "manifest.json" && (entry.type !== "File" || entry.size > MAX_METADATA_BYTES))
|
|
565
|
+
if ((name === "manifest.json" || name === "secrets.json") && (entry.type !== "File" || entry.size > MAX_METADATA_BYTES))
|
|
652
566
|
problem ||= "Invalid backup metadata file.";
|
|
653
567
|
bytes += entry.size;
|
|
654
568
|
if (bytes > MAX_WORKSPACE_BACKUP_BYTES + MAX_METADATA_BYTES)
|
|
@@ -670,7 +584,7 @@ async function inspectTar(path) {
|
|
|
670
584
|
}
|
|
671
585
|
function validateStaged(directory, expected) {
|
|
672
586
|
const manifest = validateManifest(privateJson(join(directory, "manifest.json")));
|
|
673
|
-
if (expected && expected.size !== manifest.entries.length + 2)
|
|
587
|
+
if (expected && expected.size !== manifest.entries.length + (expected.has("secrets.json") ? 3 : 2))
|
|
674
588
|
throw new Error("Archive entries do not match the backup manifest.");
|
|
675
589
|
const declared = new Set(manifest.entries.map((entry) => entry.path));
|
|
676
590
|
const inspect = (root, prefix = "") => {
|
|
@@ -704,14 +618,14 @@ function validateStaged(directory, expected) {
|
|
|
704
618
|
const data = join(directory, "data");
|
|
705
619
|
if (countJsonArray(join(data, "bots.json")) !== manifest.summary.bots || countJsonArray(join(data, "groups.json")) !== manifest.summary.groups)
|
|
706
620
|
throw new Error("The backup roster does not match its summary.");
|
|
707
|
-
if (existsSync(join(data, "config.json"))) {
|
|
621
|
+
if (existsSync(join(data, "config.json")) && !manifest.summary.secrets) {
|
|
708
622
|
const config = privateJson(join(data, "config.json"));
|
|
709
623
|
if (JSON.stringify(config) !== JSON.stringify(portableWorkspaceConfig(config)))
|
|
710
624
|
throw new Error("Workspace backup contains non-portable connection settings.");
|
|
711
625
|
}
|
|
712
626
|
if (existsSync(join(data, "webhooks.json"))) {
|
|
713
627
|
const value = privateJson(join(data, "webhooks.json"));
|
|
714
|
-
if (!record(value) || Array.isArray(value.webhooks) && value.webhooks.some((webhook) => record(webhook) && Object.hasOwn(webhook, "secretHash")))
|
|
628
|
+
if (!record(value) || !manifest.summary.secrets && Array.isArray(value.webhooks) && value.webhooks.some((webhook) => record(webhook) && Object.hasOwn(webhook, "secretHash")))
|
|
715
629
|
throw new Error("Workspace backup contains webhook credentials.");
|
|
716
630
|
}
|
|
717
631
|
const counts = databaseCounts(join(data, "messages.db"));
|
|
@@ -724,13 +638,30 @@ export async function stageWorkspaceBackup(dataDir, archivePath, options) {
|
|
|
724
638
|
const plaintext = join(job.directory, "archive.tar");
|
|
725
639
|
try {
|
|
726
640
|
// No extraction, parser or metadata processing occurs until GCM final()
|
|
727
|
-
// authenticates the complete encrypted file.
|
|
728
|
-
|
|
641
|
+
// authenticates the complete encrypted file (or its checksum matches).
|
|
642
|
+
options.onProgress?.({ phase: "checking" });
|
|
643
|
+
const { encrypted } = await readArchive(archivePath, plaintext, options.password);
|
|
729
644
|
const expected = await inspectTar(plaintext);
|
|
730
645
|
const staged = join(job.directory, "staged");
|
|
731
646
|
folder(staged);
|
|
647
|
+
options.onProgress?.({ phase: "unpacking" });
|
|
732
648
|
await tar.x({ file: plaintext, cwd: staged, strict: true, preservePaths: false, umask: 0o077, noChmod: true });
|
|
649
|
+
// The desktop secrets leave the staging tree at once; the caller holds
|
|
650
|
+
// them in memory until the person confirms the restore.
|
|
651
|
+
let secrets = null;
|
|
652
|
+
if (expected.has("secrets.json")) {
|
|
653
|
+
const value = privateJson(join(staged, "secrets.json"));
|
|
654
|
+
rmSync(join(staged, "secrets.json"));
|
|
655
|
+
if (!validSecretsBundle(value))
|
|
656
|
+
throw new Error("The backup's saved credentials are unreadable. Nothing was restored.");
|
|
657
|
+
secrets = value;
|
|
658
|
+
}
|
|
659
|
+
options.onProgress?.({ phase: "verifying" });
|
|
733
660
|
const manifest = validateStaged(staged, expected);
|
|
661
|
+
// A plain file is only checksummed, so it can never claim to hold secrets;
|
|
662
|
+
// backups from before these fields existed were always encrypted.
|
|
663
|
+
if ((manifest.summary.encrypted ?? true) !== encrypted || (!!secrets && !manifest.summary.secrets))
|
|
664
|
+
throw new Error("This backup's contents do not match its encryption. Nothing was restored.");
|
|
734
665
|
const versions = [manifest.summary.appVersion, options.currentAppVersion ?? ""].map((version) => /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version)?.slice(1).map(Number));
|
|
735
666
|
if (versions[0] && versions[1]) {
|
|
736
667
|
for (let i = 0; i < 3; i++) {
|
|
@@ -742,7 +673,7 @@ export async function stageWorkspaceBackup(dataDir, archivePath, options) {
|
|
|
742
673
|
}
|
|
743
674
|
rmSync(plaintext);
|
|
744
675
|
writeJson(join(job.directory, "ready.json"), { id: job.id, summary: manifest.summary });
|
|
745
|
-
return { id: job.id, summary: manifest.summary };
|
|
676
|
+
return { id: job.id, summary: manifest.summary, secrets };
|
|
746
677
|
}
|
|
747
678
|
catch (error) {
|
|
748
679
|
rmSync(job.directory, { recursive: true, force: true });
|
|
@@ -786,7 +717,7 @@ function readJournal(path) {
|
|
|
786
717
|
if (!record(raw) || typeof raw.id !== "string" || !ID.test(raw.id) || !["applying", "committed", "rolled-back"].includes(String(raw.phase)))
|
|
787
718
|
throw new Error("Invalid restore recovery journal. Original data was preserved; recovery needs attention.");
|
|
788
719
|
for (const key of ["existing", "incoming"]) {
|
|
789
|
-
if (!Array.isArray(raw[key]) || !raw[key].every((name) => typeof name === "string" && validRelative(name) && !name.includes("/") && !preserved(name)) || new Set(raw[key]).size !== raw[key].length)
|
|
720
|
+
if (!Array.isArray(raw[key]) || !raw[key].every((name) => typeof name === "string" && validRelative(name) && !name.includes("/") && !preserved(name, raw.secrets === true)) || new Set(raw[key]).size !== raw[key].length)
|
|
790
721
|
throw new Error("Invalid restore recovery paths.");
|
|
791
722
|
}
|
|
792
723
|
return raw;
|
|
@@ -823,38 +754,11 @@ function rollback(dataDir, journal) {
|
|
|
823
754
|
rmSync(last);
|
|
824
755
|
}
|
|
825
756
|
}
|
|
826
|
-
function
|
|
827
|
-
// Explicit old-root prefix, not a free-form text replacement. Normalize
|
|
828
|
-
// Windows separators without interpreting unrelated external paths.
|
|
829
|
-
const normalized = value.replaceAll("\\", "/");
|
|
830
|
-
const original = source.replaceAll("\\", "/").replace(/\/$/, "");
|
|
831
|
-
if (normalized !== original && !normalized.startsWith(`${original}/`))
|
|
832
|
-
return value;
|
|
833
|
-
const suffix = normalized.slice(original.length).replace(/^\//, "");
|
|
834
|
-
if (suffix && !validRelative(suffix))
|
|
835
|
-
return value;
|
|
836
|
-
return suffix ? join(destination, suffix) : destination;
|
|
837
|
-
}
|
|
838
|
-
const PATH_KEYS = new Set(["cwd", "pinnedCwd", "workspace", "configDir", "profileDirectory", "agentDir", "dataDir", "home", "cli", "path", "filePath", "localPath"]);
|
|
839
|
-
function rebaseFields(value, source, destination) {
|
|
840
|
-
if (Array.isArray(value)) {
|
|
841
|
-
for (const item of value)
|
|
842
|
-
rebaseFields(item, source, destination);
|
|
843
|
-
}
|
|
844
|
-
else if (record(value)) {
|
|
845
|
-
for (const [key, item] of Object.entries(value)) {
|
|
846
|
-
if (PATH_KEYS.has(key) && typeof item === "string")
|
|
847
|
-
value[key] = rebasePath(item, source, destination);
|
|
848
|
-
else if (typeof item === "object")
|
|
849
|
-
rebaseFields(item, source, destination);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
function rebaseMessage(message, source, destination) {
|
|
757
|
+
function rebaseMessage(message, pairs) {
|
|
854
758
|
if (!record(message))
|
|
855
759
|
return;
|
|
856
760
|
for (const field of ["attachments", "images", "fileAttachments", "file", "card"])
|
|
857
|
-
rebaseFields(message[field],
|
|
761
|
+
rebaseFields(message[field], pairs);
|
|
858
762
|
if (typeof message.text !== "string")
|
|
859
763
|
return;
|
|
860
764
|
// Only actual top-level attachment markup is rewritten. Quoted prose,
|
|
@@ -877,7 +781,7 @@ function rebaseMessage(message, source, destination) {
|
|
|
877
781
|
continue;
|
|
878
782
|
const next = node.value.replace(/(<attached-(?:image|file)[\t ]+path=")([^"\r\n]*)(")/g, (whole, before, encoded, after) => {
|
|
879
783
|
const attachment = [...parsed.images, ...parsed.files].find((item) => escapeAttribute(item.path) === encoded);
|
|
880
|
-
return attachment ? `${before}${escapeAttribute(rebasePath(attachment.path,
|
|
784
|
+
return attachment ? `${before}${escapeAttribute(rebasePath(attachment.path, pairs))}${after}` : whole;
|
|
881
785
|
});
|
|
882
786
|
if (next !== node.value)
|
|
883
787
|
edits.push({ from: node.position.start.offset, to: node.position.end.offset, text: next });
|
|
@@ -887,8 +791,14 @@ function rebaseMessage(message, source, destination) {
|
|
|
887
791
|
next = next.slice(0, edit.from) + edit.text + next.slice(edit.to);
|
|
888
792
|
message.text = next;
|
|
889
793
|
}
|
|
794
|
+
/** Old roots to new ones for this computer: data folder, then home folder. */
|
|
795
|
+
function pathPairs(dataDir, manifest) {
|
|
796
|
+
return restorePathPairs({ dataDir: manifest.sourceDataDir, home: manifest.sourceHome }, { dataDir: resolve(dataDir), home: homedir() });
|
|
797
|
+
}
|
|
890
798
|
function prepareRestore(dataDir, id, manifest) {
|
|
891
799
|
assertLocalAuthOutsideSnapshot(dataDir);
|
|
800
|
+
const pairs = pathPairs(dataDir, manifest);
|
|
801
|
+
const secrets = manifest.summary.secrets === true;
|
|
892
802
|
const job = jobPath(dataDir, id);
|
|
893
803
|
const prepared = join(job, "apply", "data");
|
|
894
804
|
if (existsSync(join(job, "apply")))
|
|
@@ -906,6 +816,14 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
906
816
|
if (entry.type === "file")
|
|
907
817
|
copyRegular(join(job, "staged", "data", entry.path), destination);
|
|
908
818
|
}
|
|
819
|
+
// Paths are judged against the copy being installed, as it will be.
|
|
820
|
+
const unresolved = { paths: new Set(), exists: (path) => {
|
|
821
|
+
const inside = relative(resolve(dataDir), path);
|
|
822
|
+
return inside && !inside.startsWith("..") && !isAbsolute(inside) ? entryExists(join(prepared, inside)) : existsSync(path);
|
|
823
|
+
} };
|
|
824
|
+
const skippedLinks = restoreLinks(prepared, dataDir, manifest.links ?? [], pairs, unresolved.paths);
|
|
825
|
+
if (pairs.length)
|
|
826
|
+
rebaseTree(prepared, pairs);
|
|
909
827
|
const changeJson = (name, change) => {
|
|
910
828
|
const path = join(prepared, name);
|
|
911
829
|
if (!existsSync(path))
|
|
@@ -914,8 +832,8 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
914
832
|
change(value);
|
|
915
833
|
writeJson(path, value);
|
|
916
834
|
};
|
|
917
|
-
for (const name of ["bots.json", "groups.json", "
|
|
918
|
-
changeJson(name, (value) => rebaseFields(value,
|
|
835
|
+
for (const name of ["bots.json", "groups.json", "routines.json", "calendar-calls.json"]) {
|
|
836
|
+
changeJson(name, (value) => rebaseFields(value, pairs, unresolved));
|
|
919
837
|
}
|
|
920
838
|
// Voice provider configuration and credentials deliberately stay with the
|
|
921
839
|
// destination installation. Imported per-agent ids belong to the source
|
|
@@ -928,13 +846,23 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
928
846
|
if (record(bot))
|
|
929
847
|
delete bot.voice;
|
|
930
848
|
});
|
|
931
|
-
//
|
|
932
|
-
// driver environments and MCP configuration
|
|
849
|
+
// Without secrets, never install source connection settings: destination
|
|
850
|
+
// keys, endpoints, driver environments and MCP configuration stay paired.
|
|
851
|
+
// A secrets backup is a clone and brings its own, with every path rebased.
|
|
933
852
|
const oldConfig = join(dataDir, "config.json");
|
|
934
853
|
const newConfig = join(prepared, "config.json");
|
|
935
|
-
if (existsSync(oldConfig) || existsSync(newConfig)) {
|
|
854
|
+
if (!secrets && (existsSync(oldConfig) || existsSync(newConfig))) {
|
|
936
855
|
writeJson(newConfig, restoredWorkspaceConfig(existsSync(newConfig) ? privateJson(newConfig) : {}, existsSync(oldConfig) ? privateJson(oldConfig) : {}));
|
|
937
856
|
}
|
|
857
|
+
changeJson("config.json", (value) => rebaseFields(value, pairs, unresolved));
|
|
858
|
+
// The native vault's key is sealed to this computer; the desktop replaces
|
|
859
|
+
// it with the backup's own key once the restored files are in place.
|
|
860
|
+
const vaultKey = join(dataDir, "providers", "native-vault", "key.bin");
|
|
861
|
+
if (secrets && entryExists(vaultKey)) {
|
|
862
|
+
// The restored providers folder replaces this one; this computer's sealed key stays.
|
|
863
|
+
folder(join(prepared, "providers", "native-vault"));
|
|
864
|
+
copyFileSync(vaultKey, join(prepared, "providers", "native-vault", "key.bin"));
|
|
865
|
+
}
|
|
938
866
|
// Browser authentication lives alongside ordinary VM files. Copy only the
|
|
939
867
|
// destination's excluded subtrees into the install copy, never the archive.
|
|
940
868
|
// They then participate in the same top-level swap and crash rollback.
|
|
@@ -988,6 +916,8 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
988
916
|
webhook.enabled = false;
|
|
989
917
|
webhook.verificationPending = false;
|
|
990
918
|
const previous = oldHooks.find((old) => record(old) && old.id === webhook.id && old.endpointId === webhook.endpointId);
|
|
919
|
+
if (secrets && typeof webhook.secretHash === "string" && /^[a-f0-9]{64}$/.test(webhook.secretHash))
|
|
920
|
+
continue;
|
|
991
921
|
// Unknown random hashes cannot authenticate a caller. A migrated hook
|
|
992
922
|
// needs an explicit secret rotation before it can receive anything.
|
|
993
923
|
webhook.secretHash = record(previous) && typeof previous.secretHash === "string" && /^[a-f0-9]{64}$/.test(previous.secretHash) ? previous.secretHash : randomBytes(32).toString("hex");
|
|
@@ -1010,10 +940,10 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
1010
940
|
changeJson(entry.path, (value) => {
|
|
1011
941
|
if (Array.isArray(value))
|
|
1012
942
|
for (const message of value)
|
|
1013
|
-
rebaseMessage(message,
|
|
943
|
+
rebaseMessage(message, pairs);
|
|
1014
944
|
else if (record(value) && Array.isArray(value.messages))
|
|
1015
945
|
for (const message of value.messages)
|
|
1016
|
-
rebaseMessage(message,
|
|
946
|
+
rebaseMessage(message, pairs);
|
|
1017
947
|
});
|
|
1018
948
|
}
|
|
1019
949
|
const dbPath = join(prepared, "messages.db");
|
|
@@ -1021,11 +951,11 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
1021
951
|
const db = new DatabaseSync(dbPath);
|
|
1022
952
|
try {
|
|
1023
953
|
db.exec("BEGIN");
|
|
1024
|
-
if (
|
|
954
|
+
if (pairs.length) {
|
|
1025
955
|
const update = db.prepare("UPDATE messages SET json = ?, text = ? WHERE thread_id = ? AND id = ?");
|
|
1026
956
|
for (const row of db.prepare("SELECT thread_id, id, json FROM messages").iterate()) {
|
|
1027
957
|
const message = JSON.parse(String(row.json));
|
|
1028
|
-
rebaseMessage(message,
|
|
958
|
+
rebaseMessage(message, pairs);
|
|
1029
959
|
const json = JSON.stringify(message);
|
|
1030
960
|
if (json !== row.json)
|
|
1031
961
|
update.run(json, record(message) && typeof message.text === "string" ? message.text : null, row.thread_id, row.id);
|
|
@@ -1038,7 +968,7 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
1038
968
|
const pause = db.prepare("UPDATE chat_followups SET status = 'interrupted', payload = ? WHERE id = ?");
|
|
1039
969
|
for (const row of db.prepare("SELECT id, payload FROM chat_followups WHERE status IN ('pending', 'dispatching', 'interrupted')").iterate()) {
|
|
1040
970
|
const payload = JSON.parse(String(row.payload));
|
|
1041
|
-
rebaseMessage(payload,
|
|
971
|
+
rebaseMessage(payload, pairs);
|
|
1042
972
|
if (record(payload))
|
|
1043
973
|
delete payload.prompt;
|
|
1044
974
|
pause.run(JSON.stringify(payload), row.id);
|
|
@@ -1050,6 +980,8 @@ function prepareRestore(dataDir, id, manifest) {
|
|
|
1050
980
|
db.close();
|
|
1051
981
|
}
|
|
1052
982
|
}
|
|
983
|
+
// finishRestore puts these on the receipt Settings shows after restart.
|
|
984
|
+
writeJson(join(job, "unresolved.json"), [...unresolved.paths, ...skippedLinks.map((path) => join(resolve(dataDir), path))].slice(0, MAX_REPORTED_PATHS));
|
|
1053
985
|
return prepared;
|
|
1054
986
|
}
|
|
1055
987
|
export function readLastWorkspaceRestore(dataDir) {
|
|
@@ -1070,12 +1002,15 @@ function finishRestore(dataDir, id, consumePending = true) {
|
|
|
1070
1002
|
try {
|
|
1071
1003
|
const value = JSON.parse(draftAttachments);
|
|
1072
1004
|
const manifest = validateManifest(privateJson(join(jobPath(dataDir, id), "staged", "manifest.json")));
|
|
1073
|
-
rebaseFields(value,
|
|
1005
|
+
rebaseFields(value, pathPairs(dataDir, manifest));
|
|
1074
1006
|
clientState["omb-draft-attachments"] = JSON.stringify(value);
|
|
1075
1007
|
}
|
|
1076
1008
|
catch { /* A malformed client draft must not jeopardize durable server data. */ }
|
|
1077
1009
|
}
|
|
1078
|
-
const
|
|
1010
|
+
const unresolvedFile = join(jobPath(dataDir, id), "unresolved.json");
|
|
1011
|
+
const listed = existsSync(unresolvedFile) ? privateJson(unresolvedFile) : [];
|
|
1012
|
+
const unresolvedPaths = Array.isArray(listed) ? listed.filter((path) => typeof path === "string") : [];
|
|
1013
|
+
const result = { restored: true, id, safetyCopyPath: join(root, `safety-${id}`), summary, clientState, unresolvedPaths };
|
|
1079
1014
|
writeJson(join(root, "last-restore.json"), result);
|
|
1080
1015
|
if (consumePending)
|
|
1081
1016
|
rmSync(join(root, "pending-restore.json"), { force: true });
|
|
@@ -1113,15 +1048,16 @@ export function applyPendingWorkspaceRestore(dataDir) {
|
|
|
1113
1048
|
if (existsSync(safetyCopyPath))
|
|
1114
1049
|
throw new Error("A safety copy already exists for this restore. Original files have not been changed.");
|
|
1115
1050
|
folder(join(safetyCopyPath, "data"));
|
|
1051
|
+
const secrets = manifest.summary.secrets === true;
|
|
1116
1052
|
const journal = {
|
|
1117
1053
|
id: pending.id, phase: "applying",
|
|
1118
|
-
existing: readdirSync(dataDir).filter((name) => !preserved(name)).sort(),
|
|
1119
|
-
incoming: readdirSync(prepared).sort(),
|
|
1054
|
+
existing: readdirSync(dataDir).filter((name) => !preserved(name, secrets)).sort(),
|
|
1055
|
+
incoming: readdirSync(prepared).sort(), secrets,
|
|
1120
1056
|
};
|
|
1121
1057
|
// Top-level source and destination names are checked before recording any
|
|
1122
1058
|
// move; a restored root never traverses an archive-controlled directory.
|
|
1123
1059
|
for (const name of [...journal.existing, ...journal.incoming]) {
|
|
1124
|
-
if (!validRelative(name) || name.includes("/") || preserved(name))
|
|
1060
|
+
if (!validRelative(name) || name.includes("/") || preserved(name, secrets))
|
|
1125
1061
|
throw new Error("Unsafe workspace restore target.");
|
|
1126
1062
|
}
|
|
1127
1063
|
writeJson(join(safetyCopyPath, "manifest.json"), { id: pending.id, createdAt: new Date().toISOString(), originalEntries: journal.existing });
|