@chibitek/chibi 0.1.172 → 0.1.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-CGoW52MW.js → abnfDiagram-O67JEVCF-DaA9WpZw.js} +1 -1
  2. package/dist/assets/{arc-DEkKLw4F.js → arc-DJ4Kdvv6.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-Cyi6MYs6.js → architectureDiagram-NJMV4G6O-B975BUTv.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-CddA0wa_.js → blockDiagram-BEXU5L5S-CXOOfLy7.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-Dg8q9n90.js → c4Diagram-YGBWAQC7-CorqQ94E.js} +1 -1
  6. package/dist/assets/channel-sBYHnjxz.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BSMub34c.js → chunk-2Q5K7J3B-CPFHpKez.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW--Xc7Up5y.js → chunk-3FUC2YCW-CrzBuB3A.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-B9Ae4f30.js → chunk-5VM5RSS4-DXJTBdvO.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-Dh1To9G3.js → chunk-GTNCS2PH-B0TxALG4.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-C_5eiV2n.js → chunk-GWA4HPMP-BL44uVOP.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-5wQuPsks.js → chunk-JWPE2WC7-CxSklH7P.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-BGXS05ur.js → chunk-NETBCI7D-_Lb-D8AY.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-ClIMhmD5.js → chunk-WEXAMYUT-Dz5jp0xb.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-CcKs11-L.js → chunk-XXDRQBXY-C-cd9N0r.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-BCiLckGU.js → classDiagram-v2-NBCMYWYE-DtQ1781-.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-JyAqT-ND.js → cose-bilkent-JH36ORCC-CHzWXNdH.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-BznovZj0.js → cynefin-OW5HDTMX-lHbKcf1S.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-BDRrTIEB.js → cynefinDiagram-VND7K2PF-BxY6MCKD.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BbWguhLj.js → dagre-6A5THRUB-C2SBw-Mo.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-BMk1KTBr.js → diagram-22UHCM2B-BwIAlmAU.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-ChnEP2in.js → diagram-3UASUU5V-i6fBkckD.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-C78imnTV.js → diagram-ATOU4E4O-DwedCbB_.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-Dekx02f_.js → diagram-CDSNMT55-BISF-AQC.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-D2__fPwe.js → diagram-MLGK6HIB-ImgdRRxp.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-C9l7MZm7.js → diagram-MPIPVDR6-CxlAwPCS.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-D4x0h3XT.js → ebnfDiagram-ZINNZB2B-sVJkymKa.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-twChz5io.js → elk-276RUBZZ-DU1vyX4w.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-BXEKWWMq.js → erDiagram-OPXOYQCR-CnCmL1Ve.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-Ct-XrJMr.js → flowDiagram-KWPJA3E3-DME2uLJf.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-Bn92lxCs.js → ganttDiagram-FUAMR5RP-CJQNW9ts.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-Dvfjo7cn.js → gitGraphDiagram-X574FWY7-D88_Y9tg.js} +1 -1
  33. package/dist/assets/{index-CarzpTfN.js → index-ChmZZe7i.js} +1 -1
  34. package/dist/assets/{index-DvneZE6Z.js → index-U9LN4GhF.js} +140 -140
  35. package/dist/assets/{infoDiagram-VRGFBTTK-MJ14OyNX.js → infoDiagram-VRGFBTTK-CxWEf3NI.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-OU5B5YK6-nf9xhTNX.js → ishikawaDiagram-OU5B5YK6-B_YoNVjF.js} +1 -1
  37. package/dist/assets/{journeyDiagram-ZHPQQLJL-DqfK6X2e.js → journeyDiagram-ZHPQQLJL-ZYuBE2Bm.js} +1 -1
  38. package/dist/assets/{kanban-definition-PNTS6WVX-CjUR1QYT.js → kanban-definition-PNTS6WVX-jmY_M4Qf.js} +1 -1
  39. package/dist/assets/{linear-dPqGsZwL.js → linear-kKUF6ayN.js} +1 -1
  40. package/dist/assets/{mermaid.core-zL0DLPAI.js → mermaid.core-DC5051S_.js} +5 -5
  41. package/dist/assets/{mindmap-definition-NLK3R4M7-C1-Jqg3_.js → mindmap-definition-NLK3R4M7-C5knfygJ.js} +1 -1
  42. package/dist/assets/{pegDiagram-GJSIUBJH-CFlvuAHC.js → pegDiagram-GJSIUBJH-Ex6iedL2.js} +1 -1
  43. package/dist/assets/{pieDiagram-5QR66LMP-BvyT0IgF.js → pieDiagram-5QR66LMP-Dv38Ox0D.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-O4NWA36T-bg7JeR2c.js → quadrantDiagram-O4NWA36T-CwOEor8N.js} +1 -1
  45. package/dist/assets/{railroadDiagram-XR7U4H2S-dF2Nqim8.js → railroadDiagram-XR7U4H2S-DVgqz5MR.js} +1 -1
  46. package/dist/assets/{requirementDiagram-PLB6GJNP-CwVu_Or6.js → requirementDiagram-PLB6GJNP-BxiuYo52.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-IPEJSGJF-wBC3-mRy.js → sankeyDiagram-IPEJSGJF--vq_1flP.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-PO4LG4MO-Bs9lHMf-.js → sequenceDiagram-PO4LG4MO-9hLpfPA6.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-d6YiGOIQ.js → sizeCapture-INFHLROL-BeGMkj9s.js} +1 -1
  50. package/dist/assets/{stateDiagram-v2-GCMORJYK--f6dscuE.js → stateDiagram-v2-GCMORJYK-BnNOMugb.js} +1 -1
  51. package/dist/assets/{swimlanes-2SLR337P-CQpzBoAc.js → swimlanes-2SLR337P-C1YG6Jq-.js} +1 -1
  52. package/dist/assets/swimlanesDiagram-TC7HE7FX-9DghUs7S.js +8 -0
  53. package/dist/assets/{timeline-definition-EJHVYXUP-DpoyUyqt.js → timeline-definition-EJHVYXUP-Co7OtwGA.js} +1 -1
  54. package/dist/assets/{usecaseDiagram-POWQR4AR-Dg7mR3GW.js → usecaseDiagram-POWQR4AR-BYHFARv3.js} +1 -1
  55. package/dist/assets/{vennDiagram-UO4OBE2U-CoEfTL7k.js → vennDiagram-UO4OBE2U-C-sdhL3h.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-VNRHLVJA-24tIY01C.js → wardleyDiagram-VNRHLVJA-wf8ZNrmQ.js} +1 -1
  57. package/dist/assets/{xychartDiagram-PMCCYNJV-CsoqIRqn.js → xychartDiagram-PMCCYNJV-BjRlkVGv.js} +1 -1
  58. package/dist/index.html +1 -1
  59. package/dist-server/index.js +2130 -1526
  60. package/dist-server/openmausbot.js +62 -0
  61. package/dist-server/pair-cli.js +62 -0
  62. package/dist-server/server/builtin-chibi.js +1 -1
  63. package/dist-server/server/engine-tiers-view.js +49 -0
  64. package/dist-server/server/engine-tiers.js +37 -55
  65. package/dist-server/server/harness-import/engine-policy.js +6 -1
  66. package/dist-server/server/index.js +12 -13
  67. package/dist-server/server/personal-bot.js +0 -26
  68. package/dist-server/server/remote-desktop.js +139 -7
  69. package/dist-server/server/routes/engine-tiers.js +12 -1
  70. package/dist-server/server/subscription-bots.js +2 -21
  71. package/dist-server/server/turn-resources.js +28 -0
  72. package/dist-server/server/workspace-backup-archive.js +138 -0
  73. package/dist-server/server/workspace-backup-http.js +50 -8
  74. package/dist-server/server/workspace-backup-private.js +83 -0
  75. package/dist-server/server/workspace-backup-secrets.js +90 -0
  76. package/dist-server/server/workspace-backup.js +171 -235
  77. package/dist-server/server/workspace-restore-paths.js +186 -0
  78. package/dist-server/shared/provider-accounts.js +29 -0
  79. package/package.json +1 -1
  80. package/dist/assets/channel-Dda8O6gd.js +0 -1
  81. package/dist/assets/swimlanesDiagram-TC7HE7FX-C1ko4o6t.js +0 -8
@@ -1,24 +1,26 @@
1
- // An encrypted filesystem snapshot, not an additive import. The caller must
2
- // quiesce writers before create/commit. Restore runs before config/store load.
3
- import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, scrypt } from "node:crypto";
4
- import { chmodSync, closeSync, constants, cpSync, createReadStream, createWriteStream, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
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
- export const MAX_WORKSPACE_BACKUP_BYTES = 10 * 1024 ** 3;
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 MAGIC = Buffer.from("OMB-WORKSPACE-1\n");
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
- "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.",
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
- /** The Private Bot's ids and thread ids in a data folder, from bots.json. */
46
- export function privateBotIds(dataDir) {
47
- const botIds = new Set();
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 10 GB backup limit.");
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 warnings = ["Stop external editors and managed desktops before exporting; files written outside this server cannot be frozen by the backup gate.", "Conversation text and user files are not redacted and may contain secrets you pasted. Keep the encrypted backup private.", RESTORE_WARNING];
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
- if (excludedWorkspaceAuthPath(path) || ephemeralWorkspaceTokenPath(path))
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
- let target;
430
- try {
431
- target = realpathSync(source);
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
- // Only the app's disposable native-discovery links may be omitted.
441
- // An arbitrary user link represents user data, even inside the root.
442
- if (!/^workspaces\/[^/]+\/\.(?:claude|agents|grok)\/skills\/[^/]+$/.test(path) ||
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 10 GB backup limit.");
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
- const iv = randomBytes(12);
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.fill(0);
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
- Object.keys(value.summary).some((key) => !["format", "version", "id", "createdAt", "appVersion", "files", "directories", "bytes", "bots", "groups", "threads", "messages", "exclusions", "warnings"].includes(key)) || !record(value.clientState) ||
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
- await decryptArchive(archivePath, plaintext, options.password);
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 rebasePath(value, source, destination) {
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], source, destination);
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, source, destination))}${after}` : whole;
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", "config.json", "routines.json", "calendar-calls.json"]) {
918
- changeJson(name, (value) => rebaseFields(value, manifest.sourceDataDir, resolve(dataDir)));
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
- // Never install source connection settings. Destination keys, endpoints,
932
- // driver environments and MCP configuration remain paired and unchanged.
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, manifest.sourceDataDir, resolve(dataDir));
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, manifest.sourceDataDir, resolve(dataDir));
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 (manifest.sourceDataDir !== resolve(dataDir)) {
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, manifest.sourceDataDir, resolve(dataDir));
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, manifest.sourceDataDir, resolve(dataDir));
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, manifest.sourceDataDir, resolve(dataDir));
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 result = { restored: true, id, safetyCopyPath: join(root, `safety-${id}`), summary, clientState };
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 });