openmausbot 0.1.71 → 0.1.73

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 (48) hide show
  1. package/dist/assets/index-BLJ7byG3.js +296 -0
  2. package/dist/assets/{index-CJtyBonr.js → index-BYbK7ber.js} +1 -1
  3. package/dist/assets/index-De2TRLxu.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +26 -2
  7. package/dist-server/drivers/agents-proxy.js +2 -2
  8. package/dist-server/index.js +7603 -2509
  9. package/dist-server/local-computer.js +4 -0
  10. package/dist-server/openmausbot.js +1639 -581
  11. package/dist-server/pair-cli.js +1639 -581
  12. package/dist-server/server/bot-overview.js +28 -0
  13. package/dist-server/server/browser-engine.js +2 -1
  14. package/dist-server/server/calendar-calls.js +1 -0
  15. package/dist-server/server/cli.js +102 -1
  16. package/dist-server/server/config.js +57 -5
  17. package/dist-server/server/drivers/agents-proxy.js +2 -2
  18. package/dist-server/server/drivers/claude.js +14 -7
  19. package/dist-server/server/drivers/codex.js +64 -16
  20. package/dist-server/server/fleet-agent.js +161 -0
  21. package/dist-server/server/fleet-cli.js +225 -0
  22. package/dist-server/server/fleet-client.js +45 -0
  23. package/dist-server/server/fleet.js +410 -0
  24. package/dist-server/server/index.js +340 -15
  25. package/dist-server/server/local-computer.js +4 -0
  26. package/dist-server/server/mcp-registry.js +71 -3
  27. package/dist-server/server/prices.js +16 -0
  28. package/dist-server/server/provider-auth-sessions.js +3 -0
  29. package/dist-server/server/provider-key-check.js +66 -0
  30. package/dist-server/server/routines.js +1 -0
  31. package/dist-server/server/skill-learn.js +15 -1
  32. package/dist-server/server/spend.js +61 -0
  33. package/dist-server/server/system-prompt.js +15 -5
  34. package/dist-server/server/tailscale.js +2 -2
  35. package/dist-server/server/usage-ledger.js +239 -0
  36. package/dist-server/server/vps-computer.js +81 -12
  37. package/dist-server/server/webhook-ingress.js +7 -2
  38. package/dist-server/server/workspace-backup-http.js +264 -0
  39. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  40. package/dist-server/server/workspace-backup-policy.js +28 -0
  41. package/dist-server/server/workspace-backup.js +1022 -0
  42. package/dist-server/shared/learn-request.js +6 -0
  43. package/dist-server/shared/workspace-backup-client.js +15 -0
  44. package/dist-server/shared/workspace-backup.js +1 -0
  45. package/dist-server/vps-container-mcp.js +26 -2
  46. package/package.json +1 -1
  47. package/dist/assets/index-CYLdYtYp.css +0 -1
  48. package/dist/assets/index-Di4ovwu5.js +0 -289
@@ -0,0 +1,1022 @@
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";
5
+ import { isAbsolute, join, parse, posix, relative, resolve, win32 } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { backup, DatabaseSync } from "node:sqlite";
8
+ import { pipeline } from "node:stream/promises";
9
+ import * as tar from "tar";
10
+ import { fromMarkdown } from "mdast-util-from-markdown";
11
+ import { writeFileAtomic } from "./atomic.js";
12
+ import { escapeAttribute, splitTranscriptAttachments } from "../src/lib/composer-attachments.js";
13
+ import { WORKSPACE_BACKUP_CLIENT_KEYS } from "../shared/workspace-backup-client.js";
14
+ import { excludedWorkspaceAuthPath, portableWorkspaceConfig, restoredWorkspaceConfig } from "./workspace-backup-policy.js";
15
+ export const MAX_WORKSPACE_BACKUP_BYTES = 10 * 1024 ** 3;
16
+ export const MAX_WORKSPACE_BACKUP_FILES = 100_000;
17
+ export const MAX_WORKSPACE_BACKUP_UPLOAD_BYTES = MAX_WORKSPACE_BACKUP_BYTES + 256 * 1024 ** 2;
18
+ 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;
22
+ 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
+ const EXCLUDED = new Set([
24
+ ".backups", "tools", "cache", ".cache", "tmp", ".tmp", "dist-native", "tunnel-runtime",
25
+ ".openmausbot-server-child", "environment-id", "sessions.json", "tunnel-account.json",
26
+ "openmausbot-server.lease", "box-create-requests.lock", "messages.db-wal", "messages.db-shm",
27
+ ]);
28
+ const EXCLUSION_NOTES = [
29
+ "Device pairing, server identity, live leases and runtime files (existing destination identities are preserved).",
30
+ "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.",
31
+ "Downloaded tools and caches; these can be installed again.",
32
+ "External project folders, CLI login homes, browser session homes, OS keychains, companion devices and other servers.",
33
+ "VM/container disk layers and remote cloud data; durable files inside this workspace are included.",
34
+ ];
35
+ const RESTORE_WARNING = "Restoring pauses routines, webhooks and calendar calls, and does not resume pending delegations or queued runs. Definitions and historical receipts are retained; review before enabling them again.";
36
+ function excluded(name) {
37
+ 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);
38
+ }
39
+ function forbiddenArchivePath(path) {
40
+ const folded = path.toLowerCase();
41
+ return excluded(folded.split("/")[0]) || excludedWorkspaceAuthPath(folded) ||
42
+ (path !== folded && ["config.json", "webhooks.json"].includes(folded));
43
+ }
44
+ function preserved(name) {
45
+ // Existing WAL/SHM belong to the old database and must move into safety,
46
+ // never accompany a different restored main database.
47
+ const folded = name.toLowerCase();
48
+ return excluded(folded) && folded !== "messages.db-wal" && folded !== "messages.db-shm";
49
+ }
50
+ function folder(path) {
51
+ mkdirSync(path, { recursive: true, mode: 0o700 });
52
+ const stat = lstatSync(path);
53
+ if (!stat.isDirectory() || stat.isSymbolicLink())
54
+ throw new Error("The backup directory must be a real directory, not a symbolic link.");
55
+ chmodSync(path, 0o700);
56
+ }
57
+ function backupRoot(dataDir) {
58
+ const root = resolve(dataDir);
59
+ if (root === parse(root).root)
60
+ throw new Error("A filesystem root cannot be a workspace backup target.");
61
+ const stat = lstatSync(root);
62
+ if (!stat.isDirectory() || stat.isSymbolicLink())
63
+ throw new Error("The workspace data directory must not be a symbolic link.");
64
+ const path = join(root, ".backups");
65
+ folder(path);
66
+ return path;
67
+ }
68
+ function jobPath(dataDir, id) {
69
+ if (!ID.test(id))
70
+ throw new Error("Invalid workspace backup identifier.");
71
+ const path = join(backupRoot(dataDir), id);
72
+ if (entryExists(path)) {
73
+ const stat = lstatSync(path);
74
+ if (!stat.isDirectory() || stat.isSymbolicLink())
75
+ throw new Error("Unsafe workspace backup job directory.");
76
+ }
77
+ return path;
78
+ }
79
+ function newJob(dataDir) {
80
+ const id = randomUUID();
81
+ const directory = jobPath(dataDir, id);
82
+ mkdirSync(directory, { mode: 0o700 });
83
+ return { id, directory };
84
+ }
85
+ function validRelative(path) {
86
+ return !!path && path.length <= 4096 && !isAbsolute(path) && !/[\\\0:]/.test(path) &&
87
+ path.split("/").every((part) => !!part && part !== "." && part !== ".." && !/[. ]$/.test(part) &&
88
+ !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));
89
+ }
90
+ function record(value) {
91
+ return value !== null && typeof value === "object" && !Array.isArray(value);
92
+ }
93
+ function entryExists(path) {
94
+ try {
95
+ lstatSync(path);
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ if (error.code === "ENOENT")
100
+ return false;
101
+ throw error;
102
+ }
103
+ }
104
+ function privateJson(path) {
105
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
106
+ try {
107
+ const stat = fstatSync(fd);
108
+ if (!stat.isFile() || stat.size > MAX_METADATA_BYTES)
109
+ throw new Error("Workspace backup metadata is too large or is not a regular file.");
110
+ return JSON.parse(readFileSync(fd, "utf8"));
111
+ }
112
+ finally {
113
+ closeSync(fd);
114
+ }
115
+ }
116
+ function writeJson(path, value) {
117
+ const text = JSON.stringify(value);
118
+ if (Buffer.byteLength(text) > MAX_METADATA_BYTES)
119
+ throw new Error("Workspace backup metadata exceeds the size limit.");
120
+ writeFileAtomic(path, text, { mode: 0o600 });
121
+ }
122
+ function privateMetadata(manifest) {
123
+ return { summary: manifest.summary, clientState: manifest.clientState };
124
+ }
125
+ function assertLocalAuthOutsideSnapshot(dataDir) {
126
+ const configPath = join(dataDir, "config.json");
127
+ const config = existsSync(configPath) ? privateJson(configPath) : {};
128
+ const instances = record(config) && record(config.instances) ? config.instances : {};
129
+ // These are the concrete auth/home overrides read by current CLI drivers,
130
+ // not a recursive guess at which arbitrary user settings contain secrets.
131
+ const fields = ["HOME", "USERPROFILE", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "GROK_HOME", "KIMI_CODE_HOME", "HERMES_HOME", "FACTORY_HOME_OVERRIDE", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "LOCALAPPDATA"];
132
+ const check = (value, field, home) => {
133
+ if (typeof value !== "string" || !value.trim())
134
+ return;
135
+ const text = value.trim();
136
+ const expanded = text.startsWith("~/") || text.startsWith("~\\") ? join(home, text.slice(2)) : text;
137
+ const resolved = resolve(expanded);
138
+ const exists = entryExists(resolved);
139
+ const path = relative(exists ? realpathSync(dataDir) : resolve(dataDir), exists ? realpathSync(resolved) : resolved).replaceAll("\\", "/");
140
+ if (path === ".." || path.startsWith("../") || isAbsolute(path))
141
+ return;
142
+ if (path && (excluded(path.split("/")[0]) || excludedWorkspaceAuthPath(path)))
143
+ return;
144
+ throw new Error(`Provider authentication storage (${field}) is inside backed-up workspace files. Move that login storage into the excluded providers folder or outside this workspace before backing up or restoring.`);
145
+ };
146
+ const environments = [process.env, ...Object.values(instances).filter(record).map((instance) => ({ ...process.env, ...(record(instance.environment) ? instance.environment : {}) }))];
147
+ for (const environment of environments) {
148
+ const home = String(environment.HOME || environment.USERPROFILE || homedir());
149
+ for (const field of fields)
150
+ check(environment[field], field, home);
151
+ }
152
+ for (const instance of Object.values(instances)) {
153
+ if (!record(instance) || instance.driver !== "claudeAgent" || !record(instance.config))
154
+ continue;
155
+ const environment = { ...process.env, ...(record(instance.environment) ? instance.environment : {}) };
156
+ check(instance.config.configDir, "config.configDir", String(environment.HOME || environment.USERPROFILE || homedir()));
157
+ }
158
+ }
159
+ function passwordKey(password, salt) {
160
+ if (typeof password !== "string" || password.length < 12 || Buffer.byteLength(password) > 1024) {
161
+ throw new Error("Use a backup password of at least 12 characters (at most 1024 bytes).");
162
+ }
163
+ return new Promise((resolveKey, reject) => {
164
+ scrypt(password, salt, 32, { N: 131_072, r: 8, p: 1, maxmem: 256 * 1024 ** 2 }, (error, key) => {
165
+ if (error)
166
+ reject(error);
167
+ else
168
+ resolveKey(key);
169
+ });
170
+ });
171
+ }
172
+ // Open without following links and copy synchronously so this server cannot
173
+ // interleave a mutation. External writers are detected where stat permits.
174
+ function copyRegular(source, destination) {
175
+ const input = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW);
176
+ let output;
177
+ try {
178
+ const before = fstatSync(input);
179
+ if (!before.isFile() || before.size > MAX_WORKSPACE_BACKUP_BYTES)
180
+ throw new Error("A workspace file is not regular or exceeds the 10 GB backup limit.");
181
+ const mode = before.mode & 0o100 ? 0o700 : 0o600;
182
+ output = openSync(destination, "wx", mode);
183
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
184
+ const hash = createHash("sha256");
185
+ let size = 0;
186
+ for (;;) {
187
+ const count = readSync(input, chunk, 0, chunk.length, null);
188
+ if (!count)
189
+ break;
190
+ size += count;
191
+ if (size > before.size)
192
+ throw new Error("A workspace file changed during backup. Stop its writer and retry.");
193
+ hash.update(chunk.subarray(0, count));
194
+ let offset = 0;
195
+ while (offset < count)
196
+ offset += writeSync(output, chunk, offset, count - offset);
197
+ }
198
+ const after = fstatSync(input);
199
+ const current = lstatSync(source);
200
+ if (size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
201
+ current.ino !== before.ino || current.dev !== before.dev || current.isSymbolicLink()) {
202
+ throw new Error("A workspace file changed during backup. Stop its writer and retry.");
203
+ }
204
+ fsyncSync(output);
205
+ return { size, mode, sha256: hash.digest("hex") };
206
+ }
207
+ finally {
208
+ closeSync(input);
209
+ if (output !== undefined)
210
+ closeSync(output);
211
+ }
212
+ }
213
+ function hashFile(path) {
214
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
215
+ try {
216
+ if (!fstatSync(fd).isFile())
217
+ throw new Error("The workspace archive contains an unsafe file.");
218
+ const hash = createHash("sha256");
219
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
220
+ for (;;) {
221
+ const count = readSync(fd, chunk, 0, chunk.length, null);
222
+ if (!count)
223
+ return hash.digest("hex");
224
+ hash.update(chunk.subarray(0, count));
225
+ }
226
+ }
227
+ finally {
228
+ closeSync(fd);
229
+ }
230
+ }
231
+ function countJsonArray(path) {
232
+ if (!existsSync(path))
233
+ return 0;
234
+ const value = privateJson(path);
235
+ if (!Array.isArray(value))
236
+ throw new Error("A workspace roster is not a valid JSON array.");
237
+ const ids = new Set();
238
+ for (const item of value) {
239
+ if (!record(item) || typeof item.id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(item.id) || ids.has(item.id))
240
+ throw new Error("A workspace roster contains an invalid or duplicate identifier.");
241
+ ids.add(item.id);
242
+ if (item.threadId !== undefined && (typeof item.threadId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(item.threadId)))
243
+ throw new Error("A workspace roster contains an invalid thread identifier.");
244
+ if (item.tasks !== undefined && (!Array.isArray(item.tasks) || !item.tasks.every((task) => record(task) && typeof task.threadId === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(task.threadId))))
245
+ throw new Error("A workspace roster contains invalid task records.");
246
+ }
247
+ return value.length;
248
+ }
249
+ function databaseCounts(path) {
250
+ if (!existsSync(path))
251
+ return { threads: 0, messages: 0 };
252
+ const db = new DatabaseSync(path, { readOnly: true });
253
+ try {
254
+ const check = db.prepare("PRAGMA quick_check").all();
255
+ if (check.length !== 1 || Object.values(check[0])[0] !== "ok")
256
+ throw new Error("The message database failed its integrity check.");
257
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('messages', 'thread_state')").all();
258
+ if (tables.length !== 2)
259
+ throw new Error("The backup does not contain a supported message database.");
260
+ const counts = db.prepare("SELECT COUNT(*) AS messages, COUNT(DISTINCT thread_id) AS threads FROM messages").get();
261
+ return { threads: Number(counts.threads), messages: Number(counts.messages) };
262
+ }
263
+ finally {
264
+ db.close();
265
+ }
266
+ }
267
+ export async function createWorkspaceBackup(dataDir, options) {
268
+ if (Object.hasOwn(options, "credentials"))
269
+ throw new Error("Workspace backups do not transfer credentials.");
270
+ assertLocalAuthOutsideSnapshot(dataDir);
271
+ const salt = randomBytes(16);
272
+ const key = await passwordKey(options.password, salt);
273
+ const job = newJob(dataDir);
274
+ const snapshot = join(job.directory, "snapshot");
275
+ folder(join(snapshot, "data"));
276
+ const entries = [];
277
+ 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];
278
+ let bytes = 0;
279
+ let skippedLinks = 0;
280
+ try {
281
+ const root = realpathSync(dataDir);
282
+ const sourceDb = join(root, "messages.db");
283
+ // The caller closes its message handle after flushing, while maintenance
284
+ // remains held. The native snapshot includes any committed WAL records.
285
+ if (existsSync(sourceDb)) {
286
+ if (lstatSync(sourceDb).isSymbolicLink())
287
+ throw new Error("The message database must not be a symbolic link.");
288
+ const db = new DatabaseSync(sourceDb, { readOnly: true });
289
+ // Node 26's native backup completion can wait for unrelated event-loop
290
+ // activity after an async scrypt. A scoped pulse prevents an idle server
291
+ // from waiting indefinitely; it owns no data and always stops here.
292
+ const completionPulse = setInterval(() => { }, 25);
293
+ try {
294
+ await backup(db, join(snapshot, "messages-snapshot.db"));
295
+ }
296
+ finally {
297
+ clearInterval(completionPulse);
298
+ db.close();
299
+ }
300
+ const snapshotDb = new DatabaseSync(join(snapshot, "messages-snapshot.db"));
301
+ try {
302
+ snapshotDb.exec("PRAGMA journal_mode = DELETE");
303
+ }
304
+ finally {
305
+ snapshotDb.close();
306
+ }
307
+ }
308
+ const walk = (directory, prefix = "") => {
309
+ for (const name of readdirSync(directory).sort()) {
310
+ if (!prefix && excluded(name))
311
+ continue;
312
+ const path = prefix ? `${prefix}/${name}` : name;
313
+ if (excludedWorkspaceAuthPath(path))
314
+ continue;
315
+ // Do not silently skip noncanonical source spellings: reject them so
316
+ // a case-sensitive host cannot export auth paths active on Windows/Mac.
317
+ if (forbiddenArchivePath(path))
318
+ throw new Error("A workspace filename conflicts with a protected authentication or runtime path.");
319
+ if (!validRelative(path))
320
+ throw new Error("A workspace filename cannot be safely restored on supported platforms.");
321
+ const source = join(directory, name);
322
+ const stat = lstatSync(source);
323
+ if (stat.isSymbolicLink()) {
324
+ let target;
325
+ try {
326
+ target = realpathSync(source);
327
+ }
328
+ catch {
329
+ throw new Error(`Cannot back up dangling symbolic link: ${path}`);
330
+ }
331
+ const rel = relative(root, target);
332
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
333
+ throw new Error(`Cannot back up symbolic link outside the workspace: ${path}`);
334
+ }
335
+ // Only the app's disposable native-discovery links may be omitted.
336
+ // An arbitrary user link represents user data, even inside the root.
337
+ if (!/^workspaces\/[^/]+\/\.(?:claude|agents|grok)\/skills\/[^/]+$/.test(path) ||
338
+ !rel.replaceAll("\\", "/").startsWith(`workspaces/${path.split("/")[1]}/skills/`)) {
339
+ throw new Error(`Cannot back up user-created symbolic link; replace it with regular files first: ${path}`);
340
+ }
341
+ skippedLinks++;
342
+ continue;
343
+ }
344
+ if (entries.length >= MAX_WORKSPACE_BACKUP_FILES)
345
+ throw new Error("The workspace exceeds the 100,000-entry backup limit.");
346
+ const destination = join(snapshot, "data", path);
347
+ if (stat.isDirectory()) {
348
+ folder(destination);
349
+ entries.push({ path, type: "directory", size: 0, mode: 0o700 });
350
+ walk(source, path);
351
+ }
352
+ else if (stat.isFile()) {
353
+ let copied;
354
+ if (path === "config.json" || path === "webhooks.json") {
355
+ const value = privateJson(source);
356
+ if (path === "config.json")
357
+ writeJson(destination, portableWorkspaceConfig(value));
358
+ else {
359
+ if (!record(value))
360
+ throw new Error("Invalid webhook definitions in workspace backup.");
361
+ if (Array.isArray(value.webhooks))
362
+ for (const webhook of value.webhooks)
363
+ if (record(webhook))
364
+ delete webhook.secretHash;
365
+ writeJson(destination, value);
366
+ }
367
+ copied = { size: lstatSync(destination).size, mode: 0o600, sha256: hashFile(destination) };
368
+ }
369
+ else
370
+ copied = copyRegular(path === "messages.db" ? join(snapshot, "messages-snapshot.db") : source, destination);
371
+ bytes += copied.size;
372
+ if (bytes > MAX_WORKSPACE_BACKUP_BYTES)
373
+ throw new Error("The workspace exceeds the 10 GB backup limit.");
374
+ entries.push({ path, type: "file", ...copied });
375
+ }
376
+ else
377
+ throw new Error(`Cannot back up special or live runtime file: ${path}`);
378
+ }
379
+ };
380
+ walk(root);
381
+ if (skippedLinks)
382
+ warnings.push(`${skippedLinks} managed skill discovery link(s) were omitted and are recreated by the app.`);
383
+ const summary = {
384
+ format: "openmaus.workspace-backup", version: 1, id: job.id, createdAt: new Date().toISOString(),
385
+ appVersion: options.appVersion ?? "unknown", files: entries.filter((entry) => entry.type === "file").length,
386
+ directories: entries.filter((entry) => entry.type === "directory").length, bytes,
387
+ bots: countJsonArray(join(snapshot, "data", "bots.json")), groups: countJsonArray(join(snapshot, "data", "groups.json")),
388
+ ...databaseCounts(join(snapshot, "data", "messages.db")),
389
+ exclusions: EXCLUSION_NOTES, warnings,
390
+ };
391
+ const manifest = {
392
+ summary, sourceDataDir: resolve(dataDir), clientState: options.clientState ?? {}, entries,
393
+ };
394
+ writeJson(join(snapshot, "manifest.json"), manifest);
395
+ // Validate our own output too: JSON serialization must never silently
396
+ // discard metadata or produce a backup the importer cannot read.
397
+ validateManifest(privateJson(join(snapshot, "manifest.json")));
398
+ const path = join(job.directory, "workspace.ombbackup");
399
+ const iv = randomBytes(12);
400
+ const header = Buffer.concat([MAGIC, salt, iv]);
401
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
402
+ cipher.setAAD(header);
403
+ writeFileSync(path, header, { mode: 0o600, flag: "wx" });
404
+ await pipeline(tar.c({ cwd: snapshot, portable: true, strict: true }, ["manifest.json", "data"]), cipher, createWriteStream(path, { flags: "a", mode: 0o600 }));
405
+ const fd = openSync(path, "a");
406
+ try {
407
+ writeSync(fd, cipher.getAuthTag());
408
+ fsyncSync(fd);
409
+ }
410
+ finally {
411
+ closeSync(fd);
412
+ }
413
+ rmSync(snapshot, { recursive: true, force: true });
414
+ return { id: job.id, path, summary };
415
+ }
416
+ catch (error) {
417
+ rmSync(job.directory, { recursive: true, force: true });
418
+ throw error;
419
+ }
420
+ finally {
421
+ key.fill(0);
422
+ }
423
+ }
424
+ function validateManifest(value) {
425
+ if (!record(value) || !record(value.summary) || value.summary.format !== "openmaus.workspace-backup" || value.summary.version !== 1 ||
426
+ typeof value.summary.id !== "string" || !ID.test(value.summary.id) || typeof value.summary.createdAt !== "string" ||
427
+ !Number.isFinite(Date.parse(value.summary.createdAt)) || typeof value.summary.appVersion !== "string" ||
428
+ typeof value.sourceDataDir !== "string" || !(posix.isAbsolute(value.sourceDataDir) || win32.isAbsolute(value.sourceDataDir)) ||
429
+ value.sourceDataDir.length > 4096 || value.sourceDataDir.includes("\0") ||
430
+ value.sourceDataDir === posix.parse(value.sourceDataDir).root || value.sourceDataDir === win32.parse(value.sourceDataDir).root ||
431
+ value.sourceDataDir.replaceAll("\\", "/").split("/").some((part) => part === "." || part === "..") ||
432
+ Object.keys(value).some((key) => !["summary", "sourceDataDir", "clientState", "entries"].includes(key)) ||
433
+ 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) ||
434
+ !Object.values(value.clientState).every((item) => typeof item === "string") || !Array.isArray(value.entries) ||
435
+ value.entries.length > MAX_WORKSPACE_BACKUP_FILES)
436
+ throw new Error("Invalid or unsupported workspace backup metadata.");
437
+ if (Object.keys(value.clientState).some((key) => !WORKSPACE_BACKUP_CLIENT_KEYS.includes(key)) ||
438
+ Buffer.byteLength(JSON.stringify(value.clientState)) > 2 * 1024 ** 2)
439
+ throw new Error("Invalid or oversized workspace client preferences.");
440
+ for (const key of ["files", "directories", "bytes", "bots", "groups", "threads", "messages"]) {
441
+ if (!Number.isSafeInteger(value.summary[key]) || value.summary[key] < 0)
442
+ throw new Error("Invalid workspace backup summary.");
443
+ }
444
+ for (const key of ["exclusions", "warnings"]) {
445
+ if (!Array.isArray(value.summary[key]) || !value.summary[key].every((item) => typeof item === "string"))
446
+ throw new Error("Invalid workspace backup summary.");
447
+ }
448
+ const names = new Map();
449
+ let bytes = 0;
450
+ let files = 0;
451
+ for (const raw of value.entries) {
452
+ if (!record(raw) || typeof raw.path !== "string" || !validRelative(raw.path) || forbiddenArchivePath(raw.path) ||
453
+ Object.keys(raw).some((key) => !["path", "type", "size", "mode", "sha256"].includes(key)) ||
454
+ !["file", "directory"].includes(String(raw.type)) || !Number.isSafeInteger(raw.size) || raw.size < 0 ||
455
+ ![0o600, 0o700].includes(raw.mode) || names.has(raw.path.toLowerCase()))
456
+ throw new Error("Unsafe or duplicate workspace backup entry.");
457
+ names.set(raw.path.toLowerCase(), String(raw.type));
458
+ if (raw.type === "file") {
459
+ if (typeof raw.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(raw.sha256))
460
+ throw new Error("A backup file has no valid checksum.");
461
+ bytes += raw.size;
462
+ files++;
463
+ }
464
+ else if (raw.size !== 0)
465
+ throw new Error("A backup directory has an invalid size.");
466
+ }
467
+ if (bytes > MAX_WORKSPACE_BACKUP_BYTES || bytes !== value.summary.bytes || files !== value.summary.files ||
468
+ names.size - files !== value.summary.directories) {
469
+ throw new Error("Workspace backup contents do not match their summary or exceed the limit.");
470
+ }
471
+ for (const path of names.keys()) {
472
+ const parts = path.split("/");
473
+ for (let i = 1; i < parts.length; i++)
474
+ if (names.get(parts.slice(0, i).join("/")) !== "directory")
475
+ throw new Error("A backup entry has an unsafe parent.");
476
+ }
477
+ return value;
478
+ }
479
+ async function decryptArchive(inputPath, plaintext, password) {
480
+ const fd = openSync(inputPath, constants.O_RDONLY | constants.O_NOFOLLOW);
481
+ let key;
482
+ try {
483
+ const stat = fstatSync(fd);
484
+ if (!stat.isFile() || stat.size < HEADER_BYTES + TAG_BYTES || stat.size > MAX_WORKSPACE_BACKUP_UPLOAD_BYTES)
485
+ throw new Error("Invalid or oversized workspace backup file.");
486
+ const header = Buffer.alloc(HEADER_BYTES);
487
+ const tag = Buffer.alloc(TAG_BYTES);
488
+ if (readSync(fd, header, 0, header.length, 0) !== header.length || !header.subarray(0, MAGIC.length).equals(MAGIC) ||
489
+ readSync(fd, tag, 0, tag.length, stat.size - TAG_BYTES) !== tag.length)
490
+ throw new Error("This is not a supported encrypted workspace backup.");
491
+ key = await passwordKey(password, header.subarray(MAGIC.length, MAGIC.length + 16));
492
+ const decipher = createDecipheriv("aes-256-gcm", key, header.subarray(MAGIC.length + 16));
493
+ decipher.setAAD(header);
494
+ decipher.setAuthTag(tag);
495
+ try {
496
+ await pipeline(createReadStream(inputPath, { fd, autoClose: false, start: HEADER_BYTES, end: stat.size - TAG_BYTES - 1 }), decipher, createWriteStream(plaintext, { flags: "wx", mode: 0o600 }));
497
+ }
498
+ catch {
499
+ throw new Error("The backup password is incorrect, or the backup file is damaged. Nothing was restored.");
500
+ }
501
+ }
502
+ finally {
503
+ key?.fill(0);
504
+ closeSync(fd);
505
+ }
506
+ }
507
+ async function inspectTar(path) {
508
+ const fd = openSync(path, "r");
509
+ const signature = Buffer.alloc(2);
510
+ try {
511
+ readSync(fd, signature, 0, 2, 0);
512
+ }
513
+ finally {
514
+ closeSync(fd);
515
+ }
516
+ // This format is uncompressed tar. Reject auto-detected gzip before tar's
517
+ // parser can inflate an authenticated but malicious decompression bomb.
518
+ if (signature[0] === 0x1f && signature[1] === 0x8b)
519
+ throw new Error("Compressed payloads are not supported in this workspace backup version.");
520
+ const entries = new Map();
521
+ const names = new Set();
522
+ let bytes = 0;
523
+ let problem = "";
524
+ await tar.t({ file: path, strict: true, onReadEntry(entry) {
525
+ const name = entry.type === "Directory" ? entry.path.replace(/\/$/, "") : entry.path;
526
+ const expectedRoot = name === "manifest.json" || name === "data" || name.startsWith("data/");
527
+ // ReadEntry normalizes backslashes on Windows; inspect the raw header too
528
+ // so the same hostile archive is rejected before extraction on every OS.
529
+ if (entry.header.path?.includes("\\") || !validRelative(name) || !expectedRoot || !["File", "Directory"].includes(entry.type) ||
530
+ names.has(name.toLowerCase()) || entries.size >= MAX_WORKSPACE_BACKUP_FILES + 2 ||
531
+ !Number.isSafeInteger(entry.size) || entry.size < 0 || (entry.type === "Directory" && entry.size !== 0)) {
532
+ problem ||= "The archive contains unsafe, duplicate or unsupported entries.";
533
+ return;
534
+ }
535
+ if (name === "manifest.json" && (entry.type !== "File" || entry.size > MAX_METADATA_BYTES))
536
+ problem ||= "Invalid backup metadata file.";
537
+ bytes += entry.size;
538
+ if (bytes > MAX_WORKSPACE_BACKUP_BYTES + MAX_METADATA_BYTES)
539
+ problem ||= "The archive exceeds the extracted size limit.";
540
+ names.add(name.toLowerCase());
541
+ entries.set(name, { type: entry.type, size: entry.size });
542
+ } });
543
+ if (problem)
544
+ throw new Error(problem);
545
+ if (entries.get("data")?.type !== "Directory" || entries.get("manifest.json")?.type !== "File")
546
+ throw new Error("The archive has no workspace data or manifest.");
547
+ for (const name of entries.keys()) {
548
+ const parts = name.split("/");
549
+ for (let i = 1; i < parts.length; i++)
550
+ if (entries.get(parts.slice(0, i).join("/"))?.type !== "Directory")
551
+ throw new Error("An archive entry has an unsafe parent.");
552
+ }
553
+ return entries;
554
+ }
555
+ function validateStaged(directory, expected) {
556
+ const manifest = validateManifest(privateJson(join(directory, "manifest.json")));
557
+ if (expected && expected.size !== manifest.entries.length + 2)
558
+ throw new Error("Archive entries do not match the backup manifest.");
559
+ const declared = new Set(manifest.entries.map((entry) => entry.path));
560
+ const inspect = (root, prefix = "") => {
561
+ for (const name of readdirSync(root)) {
562
+ const path = prefix ? `${prefix}/${name}` : name;
563
+ if (!declared.has(path))
564
+ throw new Error("Staged workspace contains files absent from its manifest.");
565
+ const stat = lstatSync(join(root, name));
566
+ if (stat.isSymbolicLink())
567
+ throw new Error("Staged workspace contains a symbolic link.");
568
+ if (stat.isDirectory())
569
+ inspect(join(root, name), path);
570
+ }
571
+ };
572
+ inspect(join(directory, "data"));
573
+ for (const entry of manifest.entries) {
574
+ const path = join(directory, "data", entry.path);
575
+ const stat = lstatSync(path);
576
+ if (stat.isSymbolicLink() || (entry.type === "file" ? !stat.isFile() || stat.size !== entry.size : !stat.isDirectory())) {
577
+ throw new Error("Staged workspace files do not match the backup manifest.");
578
+ }
579
+ if (expected) {
580
+ const archive = expected.get(`data/${entry.path}`);
581
+ if (!archive || archive.type !== (entry.type === "file" ? "File" : "Directory") || archive.size !== entry.size)
582
+ throw new Error("Archive contents do not match the backup manifest.");
583
+ }
584
+ if (entry.type === "file" && hashFile(path) !== entry.sha256)
585
+ throw new Error("A workspace backup file failed its checksum.");
586
+ chmodSync(path, entry.mode);
587
+ }
588
+ const data = join(directory, "data");
589
+ if (countJsonArray(join(data, "bots.json")) !== manifest.summary.bots || countJsonArray(join(data, "groups.json")) !== manifest.summary.groups)
590
+ throw new Error("The backup roster does not match its summary.");
591
+ if (existsSync(join(data, "config.json"))) {
592
+ const config = privateJson(join(data, "config.json"));
593
+ if (JSON.stringify(config) !== JSON.stringify(portableWorkspaceConfig(config)))
594
+ throw new Error("Workspace backup contains non-portable connection settings.");
595
+ }
596
+ if (existsSync(join(data, "webhooks.json"))) {
597
+ const value = privateJson(join(data, "webhooks.json"));
598
+ if (!record(value) || Array.isArray(value.webhooks) && value.webhooks.some((webhook) => record(webhook) && Object.hasOwn(webhook, "secretHash")))
599
+ throw new Error("Workspace backup contains webhook credentials.");
600
+ }
601
+ const counts = databaseCounts(join(data, "messages.db"));
602
+ if (counts.messages !== manifest.summary.messages || counts.threads !== manifest.summary.threads)
603
+ throw new Error("The message database does not match the backup summary.");
604
+ return manifest;
605
+ }
606
+ export async function stageWorkspaceBackup(dataDir, archivePath, options) {
607
+ const job = newJob(dataDir);
608
+ const plaintext = join(job.directory, "archive.tar");
609
+ try {
610
+ // No extraction, parser or metadata processing occurs until GCM final()
611
+ // authenticates the complete encrypted file.
612
+ await decryptArchive(archivePath, plaintext, options.password);
613
+ const expected = await inspectTar(plaintext);
614
+ const staged = join(job.directory, "staged");
615
+ folder(staged);
616
+ await tar.x({ file: plaintext, cwd: staged, strict: true, preservePaths: false, umask: 0o077, noChmod: true });
617
+ const manifest = validateStaged(staged, expected);
618
+ const versions = [manifest.summary.appVersion, options.currentAppVersion ?? ""].map((version) => /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version)?.slice(1).map(Number));
619
+ if (versions[0] && versions[1]) {
620
+ for (let i = 0; i < 3; i++) {
621
+ if (versions[0][i] > versions[1][i])
622
+ throw new Error("This backup was made by a newer OpenMausBot version. Update the app before restoring it.");
623
+ if (versions[0][i] < versions[1][i])
624
+ break;
625
+ }
626
+ }
627
+ rmSync(plaintext);
628
+ writeJson(join(job.directory, "ready.json"), { id: job.id, summary: manifest.summary });
629
+ return { id: job.id, summary: manifest.summary };
630
+ }
631
+ catch (error) {
632
+ rmSync(job.directory, { recursive: true, force: true });
633
+ throw error;
634
+ }
635
+ }
636
+ export function readStagedWorkspaceBackup(dataDir, id) {
637
+ const directory = jobPath(dataDir, id);
638
+ const ready = privateJson(join(directory, "ready.json"));
639
+ if (!record(ready) || ready.id !== id)
640
+ throw new Error("This workspace backup is not ready to restore.");
641
+ const manifest = validateManifest(privateJson(join(directory, "staged", "manifest.json")));
642
+ return { id, ...privateMetadata(manifest) };
643
+ }
644
+ export function commitPendingWorkspaceRestore(dataDir, id) {
645
+ const root = backupRoot(dataDir);
646
+ if (existsSync(join(root, "pending-restore.json")))
647
+ throw new Error("A workspace restore is already waiting for restart.");
648
+ if (existsSync(join(root, `safety-${id}`)))
649
+ throw new Error("This restore was already attempted. Preview the backup again to create a fresh restore attempt.");
650
+ const metadata = readStagedWorkspaceBackup(dataDir, id);
651
+ const manifest = validateStaged(join(jobPath(dataDir, id), "staged"));
652
+ // Discover adaptation errors before persisting a restart request. A broken
653
+ // legacy transcript or scheduler file must not trap subsequent app boots.
654
+ prepareRestore(dataDir, id, manifest);
655
+ writeJson(join(root, "pending-restore.json"), { id });
656
+ return { id, restartRequired: true, summary: metadata.summary };
657
+ }
658
+ export function readPendingWorkspaceRestoreMetadata(dataDir) {
659
+ const root = backupRoot(dataDir);
660
+ const path = join(root, "pending-restore.json");
661
+ if (!existsSync(path))
662
+ return null;
663
+ const pending = privateJson(path);
664
+ if (!record(pending) || typeof pending.id !== "string")
665
+ throw new Error("Invalid pending workspace restore.");
666
+ return readStagedWorkspaceBackup(dataDir, pending.id);
667
+ }
668
+ function readJournal(path) {
669
+ const raw = privateJson(path);
670
+ if (!record(raw) || typeof raw.id !== "string" || !ID.test(raw.id) || !["applying", "committed", "rolled-back"].includes(String(raw.phase)))
671
+ throw new Error("Invalid restore recovery journal. Original data was preserved; recovery needs attention.");
672
+ for (const key of ["existing", "incoming"]) {
673
+ 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)
674
+ throw new Error("Invalid restore recovery paths.");
675
+ }
676
+ return raw;
677
+ }
678
+ function rollback(dataDir, journal) {
679
+ const stage = join(jobPath(dataDir, journal.id), "apply", "data");
680
+ const saved = join(backupRoot(dataDir), `safety-${journal.id}`, "data");
681
+ // Existence on both sides resolves a crash between rename and journal I/O.
682
+ // New data is moved back, never deleted; originals remain recoverable.
683
+ for (const name of journal.incoming) {
684
+ const live = join(dataDir, name);
685
+ const staged = join(stage, name);
686
+ const originalMoved = !journal.existing.includes(name) || entryExists(join(saved, name));
687
+ if (originalMoved && !entryExists(staged) && entryExists(live))
688
+ renameSync(live, staged);
689
+ }
690
+ for (const name of journal.existing) {
691
+ const original = join(saved, name);
692
+ if (!entryExists(original))
693
+ continue;
694
+ const live = join(dataDir, name);
695
+ if (entryExists(live))
696
+ throw new Error("Restore rollback found conflicting files. Both copies are preserved for recovery.");
697
+ renameSync(original, live);
698
+ }
699
+ const root = backupRoot(dataDir);
700
+ const previousReceipt = join(root, `safety-${journal.id}`, "previous-restore.json");
701
+ const last = join(root, "last-restore.json");
702
+ if (existsSync(previousReceipt))
703
+ writeJson(last, privateJson(previousReceipt));
704
+ else if (existsSync(last)) {
705
+ const value = privateJson(last);
706
+ if (record(value) && value.id === journal.id)
707
+ rmSync(last);
708
+ }
709
+ }
710
+ function rebasePath(value, source, destination) {
711
+ // Explicit old-root prefix, not a free-form text replacement. Normalize
712
+ // Windows separators without interpreting unrelated external paths.
713
+ const normalized = value.replaceAll("\\", "/");
714
+ const original = source.replaceAll("\\", "/").replace(/\/$/, "");
715
+ if (normalized !== original && !normalized.startsWith(`${original}/`))
716
+ return value;
717
+ const suffix = normalized.slice(original.length).replace(/^\//, "");
718
+ if (suffix && !validRelative(suffix))
719
+ return value;
720
+ return suffix ? join(destination, suffix) : destination;
721
+ }
722
+ const PATH_KEYS = new Set(["cwd", "pinnedCwd", "workspace", "configDir", "profileDirectory", "agentDir", "dataDir", "home", "cli", "path", "filePath", "localPath"]);
723
+ function rebaseFields(value, source, destination) {
724
+ if (Array.isArray(value)) {
725
+ for (const item of value)
726
+ rebaseFields(item, source, destination);
727
+ }
728
+ else if (record(value)) {
729
+ for (const [key, item] of Object.entries(value)) {
730
+ if (PATH_KEYS.has(key) && typeof item === "string")
731
+ value[key] = rebasePath(item, source, destination);
732
+ else if (typeof item === "object")
733
+ rebaseFields(item, source, destination);
734
+ }
735
+ }
736
+ }
737
+ function rebaseMessage(message, source, destination) {
738
+ if (!record(message))
739
+ return;
740
+ for (const field of ["attachments", "images", "fileAttachments", "file", "card"])
741
+ rebaseFields(message[field], source, destination);
742
+ if (typeof message.text !== "string")
743
+ return;
744
+ // Only actual top-level attachment markup is rewritten. Quoted prose,
745
+ // code fences, pasted text and arbitrary mentions of the old path stay exact.
746
+ const text = message.text;
747
+ const edits = [];
748
+ // A tag after a single newline remains inline HTML within a Markdown
749
+ // paragraph, even though the app's attachment parser treats its whole line
750
+ // as a card. Do not descend into quotes/lists/code or arbitrary HTML blocks.
751
+ const candidates = fromMarkdown(text).children.flatMap((node) => node.type === "html" ? [node] :
752
+ node.type === "paragraph" ? node.children.filter((child) => child.type === "html") : []);
753
+ for (const node of candidates) {
754
+ if (node.position?.start.offset === undefined || node.position.end.offset === undefined || node.position.start.column !== 1)
755
+ continue;
756
+ const lineEnd = text.indexOf("\n", node.position.end.offset);
757
+ if (text.slice(node.position.end.offset, lineEnd < 0 ? text.length : lineEnd).trim())
758
+ continue;
759
+ const parsed = splitTranscriptAttachments(node.value, false);
760
+ if (parsed.display.trim() || !parsed.images.length && !parsed.files.length)
761
+ continue;
762
+ const next = node.value.replace(/(<attached-(?:image|file)[\t ]+path=")([^"\r\n]*)(")/g, (whole, before, encoded, after) => {
763
+ const attachment = [...parsed.images, ...parsed.files].find((item) => escapeAttribute(item.path) === encoded);
764
+ return attachment ? `${before}${escapeAttribute(rebasePath(attachment.path, source, destination))}${after}` : whole;
765
+ });
766
+ if (next !== node.value)
767
+ edits.push({ from: node.position.start.offset, to: node.position.end.offset, text: next });
768
+ }
769
+ let next = text;
770
+ for (const edit of edits.reverse())
771
+ next = next.slice(0, edit.from) + edit.text + next.slice(edit.to);
772
+ message.text = next;
773
+ }
774
+ function prepareRestore(dataDir, id, manifest) {
775
+ assertLocalAuthOutsideSnapshot(dataDir);
776
+ const job = jobPath(dataDir, id);
777
+ const prepared = join(job, "apply", "data");
778
+ if (existsSync(join(job, "apply")))
779
+ rmSync(join(job, "apply"), { recursive: true, force: true });
780
+ folder(prepared);
781
+ // Keep the authenticated original staging tree intact for reinspection and
782
+ // recovery. Only this installation copy has paths/scheduling adapted.
783
+ for (const entry of manifest.entries.filter((entry) => entry.type === "directory"))
784
+ folder(join(prepared, entry.path));
785
+ for (const entry of manifest.entries) {
786
+ const destination = join(prepared, entry.path);
787
+ if (entry.type === "file")
788
+ copyRegular(join(job, "staged", "data", entry.path), destination);
789
+ }
790
+ const changeJson = (name, change) => {
791
+ const path = join(prepared, name);
792
+ if (!existsSync(path))
793
+ return;
794
+ const value = privateJson(path);
795
+ change(value);
796
+ writeJson(path, value);
797
+ };
798
+ for (const name of ["bots.json", "groups.json", "config.json", "routines.json", "calendar-calls.json"]) {
799
+ changeJson(name, (value) => rebaseFields(value, manifest.sourceDataDir, resolve(dataDir)));
800
+ }
801
+ // Never install source connection settings. Destination keys, endpoints,
802
+ // driver environments and MCP configuration remain paired and unchanged.
803
+ const oldConfig = join(dataDir, "config.json");
804
+ const newConfig = join(prepared, "config.json");
805
+ if (existsSync(oldConfig) || existsSync(newConfig)) {
806
+ writeJson(newConfig, restoredWorkspaceConfig(existsSync(newConfig) ? privateJson(newConfig) : {}, existsSync(oldConfig) ? privateJson(oldConfig) : {}));
807
+ }
808
+ // Browser authentication lives alongside ordinary VM files. Copy only the
809
+ // destination's excluded subtrees into the install copy, never the archive.
810
+ // They then participate in the same top-level swap and crash rollback.
811
+ const browserHomes = ["vm-home"];
812
+ const vmHomes = join(dataDir, "vm-homes");
813
+ if (entryExists(vmHomes)) {
814
+ if (!lstatSync(vmHomes).isDirectory())
815
+ throw new Error("Stop managed desktops and repair the VM home directory before restoring.");
816
+ for (const name of readdirSync(vmHomes))
817
+ if (validRelative(name))
818
+ browserHomes.push(`vm-homes/${name}`);
819
+ }
820
+ for (const home of browserHomes) {
821
+ const homePath = join(dataDir, home);
822
+ if (!entryExists(homePath))
823
+ continue;
824
+ if (!lstatSync(homePath).isDirectory())
825
+ throw new Error("Stop managed desktops and repair the VM home directory before restoring.");
826
+ const source = join(dataDir, home, ".browser-profiles");
827
+ if (!entryExists(source))
828
+ continue;
829
+ folder(join(prepared, home));
830
+ cpSync(source, join(prepared, home, ".browser-profiles"), { recursive: true, dereference: false, verbatimSymlinks: true, errorOnExist: true, force: false });
831
+ }
832
+ changeJson("routines.json", (value) => {
833
+ if (!record(value))
834
+ throw new Error("Invalid routine definitions in workspace backup.");
835
+ if (Array.isArray(value.routines))
836
+ for (const routine of value.routines)
837
+ if (record(routine))
838
+ routine.enabled = false;
839
+ if (Array.isArray(value.runs))
840
+ for (const run of value.runs) {
841
+ if (record(run) && ["queued", "running", "waiting"].includes(String(run.status))) {
842
+ run.status = "failed";
843
+ run.error = "This queued or active run was not resumed after workspace restore.";
844
+ run.finishedAt = Date.now();
845
+ if (run.target === "room-goal")
846
+ run.goalStatus = "failed";
847
+ }
848
+ }
849
+ });
850
+ changeJson("webhooks.json", (value) => {
851
+ if (!record(value))
852
+ throw new Error("Invalid webhook definitions in workspace backup.");
853
+ const destination = existsSync(join(dataDir, "webhooks.json")) ? privateJson(join(dataDir, "webhooks.json")) : {};
854
+ const oldHooks = record(destination) && Array.isArray(destination.webhooks) ? destination.webhooks : [];
855
+ if (Array.isArray(value.webhooks))
856
+ for (const webhook of value.webhooks)
857
+ if (record(webhook)) {
858
+ webhook.enabled = false;
859
+ webhook.verificationPending = false;
860
+ const previous = oldHooks.find((old) => record(old) && old.id === webhook.id && old.endpointId === webhook.endpointId);
861
+ // Unknown random hashes cannot authenticate a caller. A migrated hook
862
+ // needs an explicit secret rotation before it can receive anything.
863
+ webhook.secretHash = record(previous) && typeof previous.secretHash === "string" && /^[a-f0-9]{64}$/.test(previous.secretHash) ? previous.secretHash : randomBytes(32).toString("hex");
864
+ }
865
+ });
866
+ changeJson("calendar-calls.json", (value) => {
867
+ if (!record(value))
868
+ throw new Error("Invalid calendar definitions in workspace backup.");
869
+ if (Array.isArray(value.calls))
870
+ for (const call of value.calls)
871
+ if (record(call))
872
+ call.nextRunAt = null;
873
+ });
874
+ if (existsSync(join(prepared, "delegations.json")))
875
+ writeJson(join(prepared, "delegations.json"), {});
876
+ if (existsSync(join(prepared, "browser-cleanups.json")))
877
+ writeJson(join(prepared, "browser-cleanups.json"), []);
878
+ for (const entry of manifest.entries) {
879
+ if (/^messages-[^/]+\.json$/.test(entry.path))
880
+ changeJson(entry.path, (value) => {
881
+ if (Array.isArray(value))
882
+ for (const message of value)
883
+ rebaseMessage(message, manifest.sourceDataDir, resolve(dataDir));
884
+ else if (record(value) && Array.isArray(value.messages))
885
+ for (const message of value.messages)
886
+ rebaseMessage(message, manifest.sourceDataDir, resolve(dataDir));
887
+ });
888
+ }
889
+ const dbPath = join(prepared, "messages.db");
890
+ if (existsSync(dbPath) && manifest.sourceDataDir !== resolve(dataDir)) {
891
+ const db = new DatabaseSync(dbPath);
892
+ try {
893
+ const update = db.prepare("UPDATE messages SET json = ?, text = ? WHERE thread_id = ? AND id = ?");
894
+ db.exec("BEGIN");
895
+ for (const row of db.prepare("SELECT thread_id, id, json FROM messages").iterate()) {
896
+ const message = JSON.parse(String(row.json));
897
+ rebaseMessage(message, manifest.sourceDataDir, resolve(dataDir));
898
+ const json = JSON.stringify(message);
899
+ if (json !== row.json)
900
+ update.run(json, record(message) && typeof message.text === "string" ? message.text : null, row.thread_id, row.id);
901
+ }
902
+ db.exec("COMMIT");
903
+ }
904
+ finally {
905
+ db.close();
906
+ }
907
+ }
908
+ return prepared;
909
+ }
910
+ export function readLastWorkspaceRestore(dataDir) {
911
+ const root = backupRoot(dataDir);
912
+ const path = join(root, "last-restore.json");
913
+ if (!existsSync(path))
914
+ return null;
915
+ const raw = privateJson(path);
916
+ if (!record(raw) || raw.restored !== true || typeof raw.id !== "string" || !ID.test(raw.id))
917
+ throw new Error("Invalid workspace restore receipt.");
918
+ return raw;
919
+ }
920
+ function finishRestore(dataDir, id, consumePending = true) {
921
+ const root = backupRoot(dataDir);
922
+ const { summary, clientState } = readStagedWorkspaceBackup(dataDir, id);
923
+ const draftAttachments = clientState["omb-draft-attachments"];
924
+ if (draftAttachments) {
925
+ try {
926
+ const value = JSON.parse(draftAttachments);
927
+ const manifest = validateManifest(privateJson(join(jobPath(dataDir, id), "staged", "manifest.json")));
928
+ rebaseFields(value, manifest.sourceDataDir, resolve(dataDir));
929
+ clientState["omb-draft-attachments"] = JSON.stringify(value);
930
+ }
931
+ catch { /* A malformed client draft must not jeopardize durable server data. */ }
932
+ }
933
+ const result = { restored: true, id, safetyCopyPath: join(root, `safety-${id}`), summary, clientState };
934
+ writeJson(join(root, "last-restore.json"), result);
935
+ if (consumePending)
936
+ rmSync(join(root, "pending-restore.json"), { force: true });
937
+ return result;
938
+ }
939
+ /** Called after the data-directory lease, before anything reads app state.
940
+ * The DATA_DIR itself and all destination identity/session/lease files stay
941
+ * in place. The previous durable tree is retained under .backups/safety-ID. */
942
+ export function applyPendingWorkspaceRestore(dataDir) {
943
+ const root = backupRoot(dataDir);
944
+ const journalFile = join(root, "restore-journal.json");
945
+ const pendingFile = join(root, "pending-restore.json");
946
+ if (existsSync(journalFile)) {
947
+ const previous = readJournal(journalFile);
948
+ if (previous.phase === "applying") {
949
+ rollback(dataDir, previous);
950
+ writeJson(journalFile, { ...previous, phase: "rolled-back" });
951
+ rmSync(pendingFile, { force: true });
952
+ return { restored: false, rolledBack: true, id: previous.id, safetyCopyPath: join(root, `safety-${previous.id}`) };
953
+ }
954
+ if (previous.phase === "committed" && existsSync(pendingFile)) {
955
+ const pending = privateJson(pendingFile);
956
+ if (record(pending) && pending.id === previous.id) {
957
+ return finishRestore(dataDir, previous.id);
958
+ }
959
+ }
960
+ }
961
+ const pending = readPendingWorkspaceRestoreMetadata(dataDir);
962
+ if (!pending)
963
+ return { restored: false };
964
+ const staged = join(jobPath(dataDir, pending.id), "staged");
965
+ const manifest = validateStaged(staged);
966
+ const prepared = prepareRestore(dataDir, pending.id, manifest);
967
+ const safetyCopyPath = join(root, `safety-${pending.id}`);
968
+ if (existsSync(safetyCopyPath))
969
+ throw new Error("A safety copy already exists for this restore. Original files have not been changed.");
970
+ folder(join(safetyCopyPath, "data"));
971
+ const journal = {
972
+ id: pending.id, phase: "applying",
973
+ existing: readdirSync(dataDir).filter((name) => !preserved(name)).sort(),
974
+ incoming: readdirSync(prepared).sort(),
975
+ };
976
+ // Top-level source and destination names are checked before recording any
977
+ // move; a restored root never traverses an archive-controlled directory.
978
+ for (const name of [...journal.existing, ...journal.incoming]) {
979
+ if (!validRelative(name) || name.includes("/") || preserved(name))
980
+ throw new Error("Unsafe workspace restore target.");
981
+ }
982
+ writeJson(join(safetyCopyPath, "manifest.json"), { id: pending.id, createdAt: new Date().toISOString(), originalEntries: journal.existing });
983
+ if (existsSync(join(root, "last-restore.json")))
984
+ writeJson(join(safetyCopyPath, "previous-restore.json"), privateJson(join(root, "last-restore.json")));
985
+ writeJson(journalFile, journal);
986
+ try {
987
+ for (const name of journal.existing)
988
+ renameSync(join(dataDir, name), join(safetyCopyPath, "data", name));
989
+ for (const name of journal.incoming)
990
+ if (entryExists(join(prepared, name)))
991
+ renameSync(join(prepared, name), join(dataDir, name));
992
+ finishRestore(dataDir, pending.id, false);
993
+ writeJson(journalFile, { ...journal, phase: "committed" });
994
+ }
995
+ catch (error) {
996
+ rollback(dataDir, journal);
997
+ writeJson(journalFile, { ...journal, phase: "rolled-back" });
998
+ rmSync(pendingFile, { force: true });
999
+ throw new Error("The workspace restore failed and the previous workspace was recovered.", { cause: error });
1000
+ }
1001
+ return finishRestore(dataDir, pending.id);
1002
+ }
1003
+ /** Delete only an unreferenced upload/export/staging job, never a safety copy
1004
+ * or a job needed by a pending restore or its latest recovery receipt. */
1005
+ export function removeWorkspaceBackupJob(dataDir, id) {
1006
+ const path = jobPath(dataDir, id);
1007
+ const root = backupRoot(dataDir);
1008
+ for (const name of ["pending-restore.json", "restore-journal.json", "last-restore.json"]) {
1009
+ if (!existsSync(join(root, name)))
1010
+ continue;
1011
+ const value = privateJson(join(root, name));
1012
+ if (record(value) && value.id === id)
1013
+ throw new Error("This backup is still needed for restore or recovery and cannot be removed.");
1014
+ }
1015
+ if (existsSync(join(root, `safety-${id}`)))
1016
+ throw new Error("This backup has a recovery copy and cannot be removed.");
1017
+ if (!entryExists(path))
1018
+ return;
1019
+ if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink())
1020
+ throw new Error("Unsafe workspace backup cleanup target.");
1021
+ rmSync(path, { recursive: true, force: true });
1022
+ }