jishushell 0.4.2-beta2 → 0.4.10

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 (75) hide show
  1. package/Dockerfile.openclaw-slim +58 -0
  2. package/INSTALL-NOTICE +7 -1
  3. package/dist/auth.js +3 -3
  4. package/dist/auth.js.map +1 -1
  5. package/dist/cli.js +517 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +21 -4
  8. package/dist/config.js +88 -54
  9. package/dist/config.js.map +1 -1
  10. package/dist/control.js +5 -5
  11. package/dist/control.js.map +1 -1
  12. package/dist/doctor.js +47 -14
  13. package/dist/doctor.js.map +1 -1
  14. package/dist/install.d.ts +1 -1
  15. package/dist/install.js +15 -29
  16. package/dist/install.js.map +1 -1
  17. package/dist/routes/backup.d.ts +2 -0
  18. package/dist/routes/backup.js +370 -0
  19. package/dist/routes/backup.js.map +1 -0
  20. package/dist/routes/instances.d.ts +1 -0
  21. package/dist/routes/instances.js +51 -11
  22. package/dist/routes/instances.js.map +1 -1
  23. package/dist/routes/setup.js +3 -5
  24. package/dist/routes/setup.js.map +1 -1
  25. package/dist/server.js +29 -1
  26. package/dist/server.js.map +1 -1
  27. package/dist/services/backup-manager.d.ts +253 -0
  28. package/dist/services/backup-manager.js +2014 -0
  29. package/dist/services/backup-manager.js.map +1 -0
  30. package/dist/services/backup-verify.d.ts +26 -0
  31. package/dist/services/backup-verify.js +240 -0
  32. package/dist/services/backup-verify.js.map +1 -0
  33. package/dist/services/instance-manager.d.ts +24 -4
  34. package/dist/services/instance-manager.js +218 -49
  35. package/dist/services/instance-manager.js.map +1 -1
  36. package/dist/services/nomad-manager.js +72 -131
  37. package/dist/services/nomad-manager.js.map +1 -1
  38. package/dist/services/process-manager.js +4 -3
  39. package/dist/services/process-manager.js.map +1 -1
  40. package/dist/services/setup-manager.d.ts +4 -2
  41. package/dist/services/setup-manager.js +268 -129
  42. package/dist/services/setup-manager.js.map +1 -1
  43. package/dist/utils/fs.d.ts +85 -0
  44. package/dist/utils/fs.js +111 -0
  45. package/dist/utils/fs.js.map +1 -0
  46. package/dist/utils/safe-json.d.ts +2 -0
  47. package/dist/utils/safe-json.js +22 -16
  48. package/dist/utils/safe-json.js.map +1 -1
  49. package/install/jishu-install-china.sh +3092 -0
  50. package/install/jishu-install.sh +310 -108
  51. package/install/jishu-uninstall.sh +276 -391
  52. package/install/post-install.sh +9 -0
  53. package/openclaw-entry.sh +15 -0
  54. package/package.json +4 -1
  55. package/public/assets/Dashboard-DhsrzJ4F.js +1 -0
  56. package/public/assets/{InitPassword-CslWYy8G.js → InitPassword-BjubiVdd.js} +1 -1
  57. package/public/assets/InstanceDetail-DMcywsof.js +17 -0
  58. package/public/assets/{Login-d45wtgVA.js → Login-CUoEZOWR.js} +1 -1
  59. package/public/assets/NewInstance-Bk0G4EiJ.js +1 -0
  60. package/public/assets/Settings-D5tHL_h5.js +1 -0
  61. package/public/assets/Setup-4t6E3Rut.js +1 -0
  62. package/public/assets/index-BJ47MWpF.css +1 -0
  63. package/public/assets/index-DbX85irc.js +16 -0
  64. package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
  65. package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
  66. package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
  67. package/public/index.html +4 -4
  68. package/public/assets/Dashboard-Dxsq690N.js +0 -1
  69. package/public/assets/InstanceDetail-DmEkMj-t.js +0 -14
  70. package/public/assets/NewInstance-Czp5-AJe.js +0 -1
  71. package/public/assets/Settings-BKMGck05.js +0 -1
  72. package/public/assets/Setup-D3rfLWjZ.js +0 -1
  73. package/public/assets/index-77Ug7feY.css +0 -1
  74. package/public/assets/index-DkDnIohs.js +0 -16
  75. package/public/assets/vendor-react-DONn7uBV.js +0 -59
@@ -0,0 +1,2014 @@
1
+ import { execFileSync, spawn as spawnChild } from "child_process";
2
+ import { createHash, randomUUID } from "crypto";
3
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ import { posix as pathPosix } from "path";
6
+ import { BACKUPS_DIR, INSTANCES_DIR, TMP_DIR } from "../config.js";
7
+ /**
8
+ * Encode an absolute filesystem path for use inside backup archives.
9
+ * Ported from OpenClaw official: encodeAbsolutePathForBackupArchive.
10
+ */
11
+ export function encodeAbsolutePathForArchive(sourcePath) {
12
+ const normalized = sourcePath.replaceAll("\\", "/");
13
+ const windowsMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
14
+ if (windowsMatch) {
15
+ const drive = (windowsMatch[1] ?? "UNKNOWN").toUpperCase();
16
+ const rest = windowsMatch[2] ?? "";
17
+ return pathPosix.join("windows", drive, rest);
18
+ }
19
+ if (normalized.startsWith("/"))
20
+ return pathPosix.join("posix", normalized.slice(1));
21
+ return pathPosix.join("relative", normalized);
22
+ }
23
+ /**
24
+ * Try to create a backup by calling the official `openclaw backup create` CLI.
25
+ * Returns ok:false if the binary is missing or the command fails.
26
+ */
27
+ export async function callOpenclawBackup(instanceId, outputDir, opts) {
28
+ const { getResolvedOpenclawBin, getOpenclawHome } = await import("./instance-manager.js");
29
+ const openclawBin = getResolvedOpenclawBin();
30
+ const openclawHome = getOpenclawHome(instanceId);
31
+ if (!existsSync(openclawBin)) {
32
+ return { ok: false, error: `openclaw binary not found: ${openclawBin}` };
33
+ }
34
+ if (!existsSync(outputDir))
35
+ mkdirSync(outputDir, { recursive: true });
36
+ // Snapshot existing .tar.gz files so we can identify what the CLI actually
37
+ // produced, instead of "newest tarball in dir" — which mis-picks any
38
+ // unrelated pre-existing archive (e.g. a stale auto-backup from earlier).
39
+ const preExisting = new Set(readdirSync(outputDir).filter(f => f.endsWith(".tar.gz")));
40
+ const args = ["backup", "create", "--output", outputDir];
41
+ if (opts.onlyConfig)
42
+ args.push("--only-config");
43
+ if (opts.noWorkspace)
44
+ args.push("--no-include-workspace");
45
+ try {
46
+ await new Promise((resolve, reject) => {
47
+ const child = spawnChild(openclawBin, args, {
48
+ env: { ...process.env, OPENCLAW_HOME: openclawHome },
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ timeout: 120_000,
51
+ });
52
+ let stderr = "";
53
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
54
+ child.on("close", (code) => {
55
+ if (code !== 0)
56
+ reject(new Error(`openclaw backup create exited ${code}: ${stderr.slice(0, 500)}`));
57
+ else
58
+ resolve();
59
+ });
60
+ child.on("error", (err) => reject(err));
61
+ });
62
+ // Pick up only entries created by THIS CLI run. If the CLI happens to
63
+ // produce more than one archive (it currently produces exactly one),
64
+ // take the newest by mtime.
65
+ const newFiles = readdirSync(outputDir)
66
+ .filter(f => f.endsWith(".tar.gz") && !preExisting.has(f))
67
+ .map(f => ({ name: f, mtime: statSync(join(outputDir, f)).mtimeMs }))
68
+ .sort((a, b) => b.mtime - a.mtime);
69
+ if (newFiles.length === 0) {
70
+ return { ok: false, error: "openclaw backup create produced no new .tar.gz file" };
71
+ }
72
+ return { ok: true, archivePath: join(outputDir, newFiles[0].name) };
73
+ }
74
+ catch (e) {
75
+ return { ok: false, error: e.message };
76
+ }
77
+ }
78
+ // ── Directory initialization ──
79
+ /** Ensure backup and tmp directories exist */
80
+ export function ensureBackupDirs() {
81
+ for (const dir of [BACKUPS_DIR, TMP_DIR]) {
82
+ if (!existsSync(dir))
83
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
84
+ }
85
+ }
86
+ // ── Tmp file management ──
87
+ const TMP_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
88
+ const EXPORTS_SUBDIR = "exports";
89
+ /**
90
+ * Get the per-instance export output directory (TMP_DIR/exports/<id>/).
91
+ * Exports live in an isolated subdirectory so the download route can enforce
92
+ * `:id` → file ownership and one instance's URL can't reach another's archive.
93
+ */
94
+ export function getInstanceExportDir(instanceId) {
95
+ const dir = join(TMP_DIR, EXPORTS_SUBDIR, instanceId);
96
+ if (!existsSync(dir))
97
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
98
+ return dir;
99
+ }
100
+ /** Clean up stale tmp files older than 30 minutes. Call on startup and periodically. */
101
+ export function cleanupStaleTmpFiles() {
102
+ if (!existsSync(TMP_DIR))
103
+ return 0;
104
+ let cleaned = 0;
105
+ const now = Date.now();
106
+ for (const entry of readdirSync(TMP_DIR)) {
107
+ const fullPath = join(TMP_DIR, entry);
108
+ // Exports live in per-instance subdirs; sweep them one file at a time so
109
+ // we don't wipe an active export collection because of a stale sibling.
110
+ if (entry === EXPORTS_SUBDIR) {
111
+ try {
112
+ for (const instanceEntry of readdirSync(fullPath)) {
113
+ const instanceDir = join(fullPath, instanceEntry);
114
+ try {
115
+ if (!statSync(instanceDir).isDirectory())
116
+ continue;
117
+ for (const fileEntry of readdirSync(instanceDir)) {
118
+ const filePath = join(instanceDir, fileEntry);
119
+ try {
120
+ const stat = statSync(filePath);
121
+ if (now - stat.mtimeMs > TMP_MAX_AGE_MS) {
122
+ rmSync(filePath, { recursive: true, force: true });
123
+ cleaned++;
124
+ console.log(`[backup] Cleaned stale export: ${instanceEntry}/${fileEntry}`);
125
+ }
126
+ }
127
+ catch { /* skip */ }
128
+ }
129
+ }
130
+ catch { /* skip */ }
131
+ }
132
+ }
133
+ catch { /* skip */ }
134
+ continue;
135
+ }
136
+ try {
137
+ const stat = statSync(fullPath);
138
+ if (now - stat.mtimeMs > TMP_MAX_AGE_MS) {
139
+ rmSync(fullPath, { recursive: true, force: true });
140
+ cleaned++;
141
+ console.log(`[backup] Cleaned stale tmp: ${entry}`);
142
+ }
143
+ }
144
+ catch {
145
+ // Skip entries that disappeared during iteration
146
+ }
147
+ }
148
+ return cleaned;
149
+ }
150
+ // ── Tmp cleanup scheduler ──
151
+ let cleanupTimer = null;
152
+ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // Check every 10 minutes
153
+ /** Start periodic tmp cleanup. Call once at server startup. */
154
+ export function startTmpCleanupScheduler() {
155
+ // Run immediately on startup
156
+ ensureBackupDirs();
157
+ cleanupStaleTmpFiles();
158
+ // Schedule periodic cleanup
159
+ if (cleanupTimer)
160
+ clearInterval(cleanupTimer);
161
+ cleanupTimer = setInterval(() => {
162
+ cleanupStaleTmpFiles();
163
+ }, CLEANUP_INTERVAL_MS);
164
+ // Don't prevent process exit
165
+ if (cleanupTimer.unref)
166
+ cleanupTimer.unref();
167
+ }
168
+ /** Stop the cleanup scheduler (for graceful shutdown). */
169
+ export function stopTmpCleanupScheduler() {
170
+ if (cleanupTimer) {
171
+ clearInterval(cleanupTimer);
172
+ cleanupTimer = null;
173
+ }
174
+ }
175
+ // ── Backup directory helpers ──
176
+ /** Get the backup directory for an instance (creates if needed) */
177
+ export function getInstanceBackupDir(instanceId) {
178
+ const dir = join(BACKUPS_DIR, instanceId);
179
+ if (!existsSync(dir))
180
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
181
+ return dir;
182
+ }
183
+ /** List all backup files for an instance, sorted by mtime descending (newest first) */
184
+ export function listInstanceBackups(instanceId) {
185
+ const dir = join(BACKUPS_DIR, instanceId);
186
+ if (!existsSync(dir))
187
+ return [];
188
+ return readdirSync(dir)
189
+ .filter(f => f.endsWith(".tar.gz"))
190
+ .map(filename => {
191
+ const stat = statSync(join(dir, filename));
192
+ let type = "manual-backup";
193
+ if (filename.startsWith("auto-backup"))
194
+ type = "auto-backup";
195
+ else if (filename.startsWith("pre-restore"))
196
+ type = "pre-restore";
197
+ return {
198
+ filename,
199
+ size: stat.size,
200
+ created_at: stat.mtime.toISOString(),
201
+ type,
202
+ };
203
+ })
204
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
205
+ }
206
+ /** List all instance IDs that have backups (including orphans) */
207
+ export function listAllBackupInstanceIds() {
208
+ if (!existsSync(BACKUPS_DIR))
209
+ return [];
210
+ return readdirSync(BACKUPS_DIR).filter(entry => {
211
+ const dir = join(BACKUPS_DIR, entry);
212
+ try {
213
+ return statSync(dir).isDirectory();
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ });
219
+ }
220
+ const instanceLocks = new Map();
221
+ const HEARTBEAT_STALE_MS = 2 * 60 * 1000; // 2 minutes without heartbeat = stale
222
+ /** Acquire an exclusive lock for an instance. Returns false if already locked. */
223
+ export function acquireInstanceLock(instanceId, operation) {
224
+ const existing = instanceLocks.get(instanceId);
225
+ if (existing) {
226
+ if (Date.now() - existing.lastHeartbeat > HEARTBEAT_STALE_MS) {
227
+ console.warn(`[backup] Force-releasing stale lock for ${instanceId} (op: ${existing.operation}, age: ${Math.round((Date.now() - existing.since) / 1000)}s)`);
228
+ instanceLocks.delete(instanceId);
229
+ }
230
+ else {
231
+ return false;
232
+ }
233
+ }
234
+ instanceLocks.set(instanceId, { operation, since: Date.now(), lastHeartbeat: Date.now() });
235
+ return true;
236
+ }
237
+ /** Update heartbeat for a held lock. Call periodically during long operations. */
238
+ export function touchInstanceLock(instanceId) {
239
+ const lock = instanceLocks.get(instanceId);
240
+ if (lock)
241
+ lock.lastHeartbeat = Date.now();
242
+ }
243
+ /** Release the lock for an instance. */
244
+ export function releaseInstanceLock(instanceId) {
245
+ instanceLocks.delete(instanceId);
246
+ }
247
+ /** Get current lock status for an instance (for status API). */
248
+ export function getInstanceLockStatus(instanceId) {
249
+ const lock = instanceLocks.get(instanceId);
250
+ if (!lock)
251
+ return null;
252
+ if (Date.now() - lock.lastHeartbeat > HEARTBEAT_STALE_MS) {
253
+ instanceLocks.delete(instanceId);
254
+ return null;
255
+ }
256
+ return { locked: true, operation: lock.operation };
257
+ }
258
+ /** Start a heartbeat interval for long operations. Returns a cleanup function. */
259
+ export function startLockHeartbeat(instanceId, intervalMs = 15_000) {
260
+ const timer = setInterval(() => touchInstanceLock(instanceId), intervalMs);
261
+ return () => clearInterval(timer);
262
+ }
263
+ /** Check if instance is locked. Throws with 409 status if locked. */
264
+ export function assertNotLocked(instanceId) {
265
+ const lock = getInstanceLockStatus(instanceId);
266
+ if (lock?.locked) {
267
+ const err = new Error(`Instance ${instanceId} is locked: ${lock.operation}`);
268
+ err.statusCode = 409;
269
+ throw err;
270
+ }
271
+ }
272
+ // ── Tar variant detection ──
273
+ let _tarVariant = null;
274
+ /** Detect whether system tar is GNU or BSD. Cached after first call. */
275
+ export function detectTarVariant() {
276
+ if (_tarVariant)
277
+ return _tarVariant;
278
+ try {
279
+ const output = execFileSync("tar", ["--version"], { encoding: "utf-8", timeout: 5000 });
280
+ _tarVariant = output.includes("GNU tar") ? "gnu" : "bsd";
281
+ }
282
+ catch {
283
+ // BSD tar may not support --version, or tar may not exist
284
+ try {
285
+ execFileSync("tar", ["--help"], { encoding: "utf-8", timeout: 5000 });
286
+ _tarVariant = "bsd";
287
+ }
288
+ catch {
289
+ throw new Error("tar command not found. Please install tar.");
290
+ }
291
+ }
292
+ console.log(`[backup] Detected tar variant: ${_tarVariant}`);
293
+ return _tarVariant;
294
+ }
295
+ // ── Secure extraction ──
296
+ const MAX_UNCOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
297
+ /** Parse tar -tvzf output into structured entries */
298
+ function parseTarVerboseOutput(output) {
299
+ const entries = [];
300
+ for (const line of output.split("\n")) {
301
+ if (!line.trim())
302
+ continue;
303
+ // Format: -rw-r--r-- user/group 12345 2026-04-08 10:00 path/to/file
304
+ // Or: lrwxrwxrwx user/group 0 2026-04-08 10:00 link -> target
305
+ // Or: drwxr-xr-x user/group 0 2026-04-08 10:00 dir/
306
+ const perms = line.charAt(0);
307
+ let type = "-";
308
+ if (perms === "l")
309
+ type = "l"; // symlink
310
+ else if (perms === "h")
311
+ type = "h"; // hardlink
312
+ else if (perms === "d")
313
+ type = "d"; // directory
314
+ // Parse size (field after user/group)
315
+ const parts = line.split(/\s+/);
316
+ // parts: [perms, user/group, size, date, time, path...]
317
+ const size = parseInt(parts[2], 10) || 0;
318
+ // Path is everything after the time field
319
+ // Find the path by looking for the portion after date+time
320
+ const pathMatch = line.match(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.+)/);
321
+ const path = pathMatch ? pathMatch[1].replace(/ -> .+$/, "").trim() : parts.slice(5).join(" ");
322
+ // Also detect symlinks from " -> " in the path
323
+ if (line.includes(" -> ") && type !== "l")
324
+ type = "l";
325
+ entries.push({ type, size, path });
326
+ }
327
+ return entries;
328
+ }
329
+ /**
330
+ * Pre-scan a tar.gz archive for security threats.
331
+ * Throws on: path traversal, symlinks, hardlinks, absolute paths, oversized.
332
+ */
333
+ export function preScanArchive(archivePath) {
334
+ detectTarVariant(); // Ensure tar exists
335
+ const output = execFileSync("tar", ["-tvzf", archivePath], {
336
+ encoding: "utf-8",
337
+ timeout: 60_000, // 60s timeout for large archives
338
+ maxBuffer: 50 * 1024 * 1024, // 50MB buffer for listing
339
+ });
340
+ const entries = parseTarVerboseOutput(output);
341
+ let totalSize = 0;
342
+ for (const entry of entries) {
343
+ // Symlinks are allowed — npm packages routinely contain
344
+ // node_modules/.bin/ symlinks pointing at sibling paths within the
345
+ // archive. The post-extract walk (verifyNoEscapes) will reject any
346
+ // symlink whose real target resolves outside the extraction directory.
347
+ // We only hard-ban hardlinks here because they can reference files
348
+ // by inode in ways that bypass the extraction root without a
349
+ // resolvable path.
350
+ // Reject hardlinks
351
+ if (entry.type === "h") {
352
+ throw new Error(`Archive contains hardlink: ${entry.path}`);
353
+ }
354
+ // Reject path traversal
355
+ if (entry.path.includes("../") || entry.path.includes("..\\")) {
356
+ throw new Error(`Archive contains path traversal: ${entry.path}`);
357
+ }
358
+ // Reject absolute paths
359
+ if (entry.path.startsWith("/")) {
360
+ throw new Error(`Archive contains absolute path: ${entry.path}`);
361
+ }
362
+ // Accumulate size
363
+ totalSize += entry.size;
364
+ if (totalSize > MAX_UNCOMPRESSED_SIZE) {
365
+ throw new Error(`Archive uncompressed size exceeds 2GB limit (${Math.round(totalSize / 1024 / 1024)}MB)`);
366
+ }
367
+ }
368
+ return { entries, totalSize };
369
+ }
370
+ /**
371
+ * Safely extract a tar.gz archive to destDir.
372
+ * Pre-scans for security threats, then extracts with safety flags.
373
+ */
374
+ export async function safeExtract(archivePath, destDir) {
375
+ // Step 1: Pre-scan
376
+ const scanResult = preScanArchive(archivePath);
377
+ // Step 2: Ensure dest directory exists
378
+ if (!existsSync(destDir))
379
+ mkdirSync(destDir, { recursive: true });
380
+ // Step 3: Build extract args based on tar variant
381
+ const variant = detectTarVariant();
382
+ const args = ["-xzf", archivePath, "-C", destDir, "--no-same-owner"];
383
+ if (variant === "gnu") {
384
+ args.push("--no-same-permissions");
385
+ }
386
+ // Step 4: Extract via spawn (non-blocking)
387
+ await new Promise((resolve, reject) => {
388
+ const child = spawnChild("tar", args, { stdio: ["ignore", "pipe", "pipe"] });
389
+ let stderr = "";
390
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
391
+ child.on("close", (code) => {
392
+ if (code !== 0) {
393
+ reject(new Error(`tar extract failed (exit ${code}): ${stderr.slice(0, 500)}`));
394
+ }
395
+ else {
396
+ resolve();
397
+ }
398
+ });
399
+ child.on("error", reject);
400
+ });
401
+ // Step 5: Post-extract verification — ensure no entry's real path
402
+ // escapes the extraction directory.
403
+ //
404
+ // Symlinks get special-cased: we treat them as opaque blobs that will
405
+ // never be followed by downstream cpSync calls (those pass
406
+ // verbatimSymlinks: true). That eliminates the "symlink-to-/etc/passwd
407
+ // then write through it" attack surface, which means a symlink's
408
+ // target doesn't need to resolve at all for the archive to be safe.
409
+ // This matters because real archives routinely contain:
410
+ // - npm node_modules/.bin/* pointing at siblings by relative path
411
+ // - docker-originated absolute paths like `/app/...` that are
412
+ // intentionally dangling on the host but valid inside a container
413
+ // Escape detection still runs for non-symlink entries via realpathSync.
414
+ const destDirReal = realpathSync(destDir);
415
+ function verifyNoEscapes(dir) {
416
+ for (const entry of readdirSync(dir)) {
417
+ const fullPath = join(dir, entry);
418
+ const stat = lstatSync(fullPath);
419
+ if (stat.isSymbolicLink()) {
420
+ // Opaque blob — skip real-path check and do not recurse.
421
+ continue;
422
+ }
423
+ // Regular files/dirs: realpath is defined. If anything resolves
424
+ // outside destDir, it's an escape attempt that slipped past the
425
+ // pre-scan (e.g. via hardlink creativity).
426
+ const real = realpathSync(fullPath);
427
+ if (!real.startsWith(destDirReal + "/") && real !== destDirReal) {
428
+ throw new Error(`Extracted entry escaped target directory: ${fullPath} -> ${real}`);
429
+ }
430
+ if (stat.isDirectory()) {
431
+ verifyNoEscapes(fullPath);
432
+ }
433
+ }
434
+ }
435
+ verifyNoEscapes(destDir);
436
+ return scanResult;
437
+ }
438
+ // ── Pack / backup helpers ──
439
+ /** Calculate SHA-256 checksum of files in a directory, sorted by relative path */
440
+ function calculateContentChecksum(baseDir, files) {
441
+ const hash = createHash("sha256");
442
+ const sorted = [...files].sort();
443
+ for (const f of sorted) {
444
+ const fullPath = join(baseDir, f);
445
+ // Use lstat so we never follow a symlink during hashing; `files` is
446
+ // built by collectFiles which already excludes symlinks, but be
447
+ // defensive in case another caller passes a symlinked entry.
448
+ let stat;
449
+ try {
450
+ stat = lstatSync(fullPath);
451
+ }
452
+ catch {
453
+ continue;
454
+ }
455
+ if (stat.isFile()) {
456
+ hash.update(readFileSync(fullPath));
457
+ }
458
+ }
459
+ return hash.digest("hex");
460
+ }
461
+ /**
462
+ * Best-effort libc detection. Returns "glibc", "musl", "unknown", or "n/a" for non-Linux.
463
+ */
464
+ function detectLibc() {
465
+ if (process.platform !== "linux")
466
+ return "n/a";
467
+ try {
468
+ const output = execFileSync("ldd", ["--version"], { encoding: "utf-8", timeout: 2000 }).toString();
469
+ if (/musl/i.test(output))
470
+ return "musl";
471
+ if (/glibc|GNU libc/i.test(output))
472
+ return "glibc";
473
+ return "unknown";
474
+ }
475
+ catch {
476
+ return "unknown";
477
+ }
478
+ }
479
+ /**
480
+ * Copy a directory tree preserving symlinks as-is, without ever following
481
+ * them. Uses lstat exclusively so dangling / absolute-path symlinks (e.g.
482
+ * `app/openclaw.mjs -> /app/...` from a docker-cp'd container tree) do not
483
+ * crash the copy with ENOENT.
484
+ *
485
+ * `filter(rel)` returns false to skip the entry; for directories, returning
486
+ * false also skips the entire subtree. `rel` is the source-relative path
487
+ * using forward slashes.
488
+ *
489
+ * Used instead of `fs.cpSync` because Node's cpSync internally calls
490
+ * `stat` (not `lstat`) during bookkeeping, which follows symlinks and
491
+ * fails on broken targets even with `verbatimSymlinks: true`.
492
+ */
493
+ function copyTreeLstat(src, dst, filter, baseRel = "") {
494
+ // Create the destination directory itself (no-op if it exists).
495
+ if (!existsSync(dst))
496
+ mkdirSync(dst, { recursive: true });
497
+ for (const entry of readdirSync(src)) {
498
+ const srcPath = join(src, entry);
499
+ const rel = baseRel ? `${baseRel}/${entry}` : entry;
500
+ if (!filter(rel, entry))
501
+ continue;
502
+ let stat;
503
+ try {
504
+ stat = lstatSync(srcPath);
505
+ }
506
+ catch {
507
+ continue;
508
+ }
509
+ const dstPath = join(dst, entry);
510
+ if (stat.isSymbolicLink()) {
511
+ // Preserve the link target verbatim — never read through it.
512
+ try {
513
+ const target = readlinkSync(srcPath);
514
+ try {
515
+ rmSync(dstPath, { force: true });
516
+ }
517
+ catch { /* ignore */ }
518
+ symlinkSync(target, dstPath);
519
+ }
520
+ catch { /* best effort — skip unreadable symlinks */ }
521
+ }
522
+ else if (stat.isDirectory()) {
523
+ copyTreeLstat(srcPath, dstPath, filter, rel);
524
+ }
525
+ else if (stat.isFile()) {
526
+ try {
527
+ copyFileSync(srcPath, dstPath);
528
+ }
529
+ catch { /* best effort */ }
530
+ }
531
+ // Special files (sockets, devices, fifos) are silently skipped — they
532
+ // don't belong in a backup archive.
533
+ }
534
+ }
535
+ /**
536
+ * Recursively collect all regular files under a directory.
537
+ *
538
+ * Uses `lstat` so symlinks are identified as symlinks (not followed) — we
539
+ * intentionally skip them so dangling/absolute symlinks (like
540
+ * `app/openclaw.mjs -> /app/...` from a docker-cp'd tree) don't blow up
541
+ * checksum calculation with ENOENT. Symlinks are preserved in the tar
542
+ * archive by the packing step; they simply don't contribute file bytes
543
+ * to the content checksum.
544
+ */
545
+ function collectFiles(dir, baseDir, excludes) {
546
+ const results = [];
547
+ if (!existsSync(dir))
548
+ return results;
549
+ for (const entry of readdirSync(dir)) {
550
+ const fullPath = join(dir, entry);
551
+ const relPath = fullPath.slice(baseDir.length + 1); // relative to baseDir
552
+ // Check exclusions
553
+ if (excludes.some(ex => relPath.startsWith(ex) || relPath.endsWith(ex)))
554
+ continue;
555
+ let stat;
556
+ try {
557
+ stat = lstatSync(fullPath);
558
+ }
559
+ catch {
560
+ continue;
561
+ }
562
+ if (stat.isSymbolicLink())
563
+ continue; // skip; don't follow
564
+ if (stat.isDirectory()) {
565
+ results.push(...collectFiles(fullPath, baseDir, excludes));
566
+ }
567
+ else if (stat.isFile()) {
568
+ results.push(relPath);
569
+ }
570
+ }
571
+ return results;
572
+ }
573
+ /** Create a manual or auto backup of an instance */
574
+ export async function backupInstance(instanceId, opts = {}) {
575
+ const backupDir = getInstanceBackupDir(instanceId);
576
+ const type = opts.type || "manual-backup";
577
+ // Auto-backup is always state-scope (small, frequent).
578
+ // Manual backup defaults to home-scope (full disaster recovery).
579
+ const scope = type === "auto-backup" ? "state" : (opts.scope ?? "home");
580
+ // Workspace is included by default; callers can pass `includeWorkspace: false`
581
+ // to drop it. `onlyConfig` implies no workspace (it's a config-only subset).
582
+ const includeWorkspace = opts.onlyConfig ? false : (opts.includeWorkspace ?? true);
583
+ // Only state scope can use the official CLI; home scope must self-pack.
584
+ if (scope === "state") {
585
+ const cliResult = await callOpenclawBackup(instanceId, backupDir, {
586
+ onlyConfig: opts.onlyConfig,
587
+ noWorkspace: !includeWorkspace,
588
+ });
589
+ if (cliResult.ok && cliResult.archivePath) {
590
+ // Rename the CLI's output to JishuShell's canonical
591
+ // "<type>-<timestamp>.tar.gz" pattern. Both listInstanceBackups()
592
+ // (filename-prefix classification) and cleanOldAutoBackups() (prefix-
593
+ // based rolling deletion) depend on this convention. Without it, an
594
+ // auto-backup created by the official CLI — whose naming is not under
595
+ // our control — would be misclassified as "manual-backup" AND would
596
+ // never be rolled out of the backups directory, causing unbounded growth.
597
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 19);
598
+ const canonicalName = `${type}-${timestamp}.tar.gz`;
599
+ const canonicalPath = join(backupDir, canonicalName);
600
+ let finalArchivePath = cliResult.archivePath;
601
+ if (canonicalPath !== cliResult.archivePath) {
602
+ try {
603
+ // rename may fail if the canonical path already exists (extremely
604
+ // unlikely at second-precision timestamp, but guard anyway).
605
+ if (existsSync(canonicalPath))
606
+ rmSync(canonicalPath, { force: true });
607
+ renameSync(cliResult.archivePath, canonicalPath);
608
+ finalArchivePath = canonicalPath;
609
+ }
610
+ catch (e) {
611
+ console.warn(`[backup] Failed to rename CLI output ${cliResult.archivePath} -> ${canonicalPath}: ${e.message}. ` +
612
+ `File will be kept under its original name; it will be listed as "manual-backup" and will NOT be auto-rotated.`);
613
+ }
614
+ }
615
+ // Official CLI produced the archive — read its manifest for return value
616
+ const tmpDir = join(TMP_DIR, `manifest-read-${Date.now()}`);
617
+ try {
618
+ mkdirSync(tmpDir, { recursive: true });
619
+ // Full extract (state-scope archives are small) so resolveArchiveRoot
620
+ // can locate manifest.json regardless of whether the CLI wrapped it
621
+ // inside a <basename>/ top-level directory.
622
+ try {
623
+ execFileSync("tar", ["-xzf", finalArchivePath, "-C", tmpDir], { timeout: 30_000 });
624
+ }
625
+ catch { /* extraction may fail, continue with defaults */ }
626
+ const archiveRoot = resolveArchiveRoot(tmpDir);
627
+ const manifestPath = join(archiveRoot, "manifest.json");
628
+ const manifest = existsSync(manifestPath)
629
+ ? JSON.parse(readFileSync(manifestPath, "utf-8"))
630
+ : { schemaVersion: 1, type };
631
+ // Ensure scope + type fields match what JishuShell asked for (official
632
+ // CLI doesn't know about our scope enum and may not write type).
633
+ if (!manifest.scope)
634
+ manifest.scope = "state";
635
+ if (!manifest.type)
636
+ manifest.type = type;
637
+ const size = statSync(finalArchivePath).size;
638
+ return {
639
+ filename: finalArchivePath.split("/").pop(),
640
+ filepath: finalArchivePath,
641
+ size,
642
+ manifest,
643
+ };
644
+ }
645
+ finally {
646
+ rmSync(tmpDir, { recursive: true, force: true });
647
+ }
648
+ }
649
+ console.log(`[backup] Official CLI unavailable (${cliResult.error}), using self-pack fallback`);
650
+ }
651
+ // Self-pack path: used for home scope always, or as fallback for state scope
652
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 19);
653
+ const filename = `${type}-${timestamp}.tar.gz`;
654
+ const outputPath = opts.outputPath || join(backupDir, filename);
655
+ return selfPackOfficialFormat(instanceId, outputPath, {
656
+ type,
657
+ scope,
658
+ includeSessions: opts.includeSessions,
659
+ includeWorkspace,
660
+ onlyConfig: opts.onlyConfig,
661
+ });
662
+ }
663
+ /**
664
+ * Pack an instance in the official OpenClaw backup archive format.
665
+ * Layout: manifest.json at root + payload/<encoded-path>/.openclaw/...
666
+ * This is the fallback when `openclaw backup create` CLI is unavailable.
667
+ */
668
+ export async function selfPackOfficialFormat(instanceId, outputPath, opts) {
669
+ const { getInstance, getOpenclawHome } = await import("./instance-manager.js");
670
+ const meta = getInstance(instanceId);
671
+ if (!meta)
672
+ throw new Error(`Instance ${instanceId} not found`);
673
+ const openclawHome = getOpenclawHome(instanceId);
674
+ const stateDir = join(openclawHome, ".openclaw");
675
+ if (!existsSync(stateDir))
676
+ throw new Error(`State directory not found: ${stateDir}`);
677
+ const scope = opts.scope ?? "state";
678
+ const encodedPath = encodeAbsolutePathForArchive(openclawHome);
679
+ const stagingDir = join(TMP_DIR, `selfpack-${instanceId}-${Date.now()}`);
680
+ const stagingHomeDir = join(stagingDir, "payload", encodedPath);
681
+ const payloadStateDir = join(stagingHomeDir, ".openclaw");
682
+ try {
683
+ if (scope === "home") {
684
+ // Home scope: pack entire openclaw-home/ tree minus cache dirs
685
+ mkdirSync(stagingHomeDir, { recursive: true });
686
+ const homeTopLevelExcludes = [
687
+ ".npm/_cacache",
688
+ ".npm/_logs",
689
+ ".npm/_npx",
690
+ ".npm/_update-notifier-last-checked",
691
+ ".cache",
692
+ ".node_compile_cache",
693
+ ];
694
+ const stateSubExcludes = [
695
+ ".npm",
696
+ ".cache",
697
+ ".node_compile_cache",
698
+ "workspace/.npm-global",
699
+ ];
700
+ const sessionExcludes = opts.includeSessions ? [] : ["agents/main/sessions"];
701
+ const workspaceExcludes = opts.includeWorkspace === false ? ["workspace"] : [];
702
+ if (opts.onlyConfig) {
703
+ // Only include openclaw.json inside .openclaw/
704
+ mkdirSync(payloadStateDir, { recursive: true });
705
+ const configSrc = join(stateDir, "openclaw.json");
706
+ if (existsSync(configSrc)) {
707
+ copyFileSync(configSrc, join(payloadStateDir, "openclaw.json"));
708
+ }
709
+ }
710
+ else {
711
+ // Use copyTreeLstat so we never follow symlinks (even dangling
712
+ // ones like `app/openclaw.mjs -> /app/...` from a docker-cp'd
713
+ // container tree). fs.cpSync cannot be used here: even with
714
+ // verbatimSymlinks: true, its internal bookkeeping calls `stat`
715
+ // on dest entries which follows broken links and crashes with
716
+ // ENOENT on the first dangling symlink.
717
+ copyTreeLstat(openclawHome, stagingHomeDir, (rel) => {
718
+ if (!rel)
719
+ return true;
720
+ if (homeTopLevelExcludes.some(ex => rel === ex || rel.startsWith(ex + "/")))
721
+ return false;
722
+ if (rel.startsWith(".npm-global/") && rel.includes("/.cache/"))
723
+ return false;
724
+ if (rel === ".openclaw" || rel.startsWith(".openclaw/")) {
725
+ const stateRel = rel === ".openclaw" ? "" : rel.slice(".openclaw/".length);
726
+ if (!stateRel)
727
+ return true;
728
+ if (stateSubExcludes.some(ex => stateRel === ex || stateRel.startsWith(ex + "/")))
729
+ return false;
730
+ if (sessionExcludes.some(ex => stateRel === ex || stateRel.startsWith(ex + "/")))
731
+ return false;
732
+ if (workspaceExcludes.some(ex => stateRel === ex || stateRel.startsWith(ex + "/")))
733
+ return false;
734
+ }
735
+ return true;
736
+ });
737
+ }
738
+ }
739
+ else {
740
+ // State scope (default): only .openclaw/ contents
741
+ mkdirSync(payloadStateDir, { recursive: true });
742
+ // Build exclusion list
743
+ const excludes = [
744
+ ".npm/",
745
+ ".cache/",
746
+ ".node_compile_cache/",
747
+ "workspace/.npm-global/",
748
+ ];
749
+ if (!opts.includeSessions)
750
+ excludes.push("agents/main/sessions/");
751
+ if (opts.includeWorkspace === false)
752
+ excludes.push("workspace/");
753
+ if (opts.onlyConfig) {
754
+ // Only copy openclaw.json
755
+ const configSrc = join(stateDir, "openclaw.json");
756
+ if (existsSync(configSrc)) {
757
+ copyFileSync(configSrc, join(payloadStateDir, "openclaw.json"));
758
+ }
759
+ }
760
+ else {
761
+ // Copy .openclaw/ contents (respecting excludes). Manual lstat
762
+ // walker — same rationale as the home-scope branch above.
763
+ copyTreeLstat(stateDir, payloadStateDir, (rel) => {
764
+ if (!rel)
765
+ return true; // root dir
766
+ return !excludes.some(ex => rel.startsWith(ex));
767
+ });
768
+ }
769
+ }
770
+ // Collect all files for checksum (excluding manifest which doesn't exist yet)
771
+ const allFiles = collectFiles(stagingDir, stagingDir, []);
772
+ // Calculate checksum from staged files
773
+ const checksum = calculateContentChecksum(stagingDir, allFiles);
774
+ const hasSessions = opts.includeSessions ?? false;
775
+ // Generate manifest (minimum required fields matching official format)
776
+ const manifest = {
777
+ schemaVersion: 1,
778
+ type: opts.type,
779
+ scope,
780
+ createdAt: new Date().toISOString(),
781
+ name: meta.name || instanceId,
782
+ description: meta.description || "",
783
+ platform: process.platform,
784
+ arch: process.arch,
785
+ libc: detectLibc(),
786
+ nodeVersion: process.version,
787
+ paths: {
788
+ stateDir: `payload/${encodedPath}/.openclaw`,
789
+ },
790
+ has_sessions: hasSessions,
791
+ checksum: `sha256:${checksum}`,
792
+ checksum_scope: "content-excluding-manifest",
793
+ };
794
+ // Write manifest to staging root
795
+ writeFileSync(join(stagingDir, "manifest.json"), JSON.stringify(manifest, null, 2));
796
+ // Single-pass tar via spawn with heartbeat
797
+ const stopHeartbeat = startLockHeartbeat(instanceId);
798
+ try {
799
+ await new Promise((resolve, reject) => {
800
+ const child = spawnChild("tar", ["-czf", outputPath, "."], {
801
+ cwd: stagingDir,
802
+ stdio: ["ignore", "pipe", "pipe"],
803
+ });
804
+ let stderr = "";
805
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
806
+ child.on("close", (code) => {
807
+ if (code !== 0)
808
+ reject(new Error(`tar pack failed (exit ${code}): ${stderr.slice(0, 500)}`));
809
+ else
810
+ resolve();
811
+ });
812
+ child.on("error", reject);
813
+ });
814
+ }
815
+ finally {
816
+ stopHeartbeat();
817
+ }
818
+ const finalSize = statSync(outputPath).size;
819
+ return { filename: outputPath.split("/").pop(), filepath: outputPath, size: finalSize, manifest };
820
+ }
821
+ finally {
822
+ // Cleanup staging
823
+ rmSync(stagingDir, { recursive: true, force: true });
824
+ }
825
+ }
826
+ /** Dynamically resolve the service manager (nomad or process) based on panel config. */
827
+ async function getSvc() {
828
+ const { getServiceManagerType } = await import("../config.js");
829
+ const type = getServiceManagerType();
830
+ if (type === "nomad") {
831
+ return import("./nomad-manager.js");
832
+ }
833
+ return import("./process-manager.js");
834
+ }
835
+ /**
836
+ * Restore an instance from a backup file.
837
+ * Full flow: lock -> extract -> validate -> pre-restore backup -> stop -> restore -> rebuild -> start -> healthcheck -> rollback on failure
838
+ */
839
+ export async function restoreInstance(instanceId, backupFilePath) {
840
+ const { getInstance } = await import("./instance-manager.js");
841
+ const meta = getInstance(instanceId);
842
+ if (!meta)
843
+ throw new Error(`Instance ${instanceId} not found`);
844
+ const warnings = [];
845
+ // Default to "preserved"; promoted to "lost" below if we detect the archive
846
+ // was built on a different machine (platform/arch mismatch is a proxy signal).
847
+ let apiKeyStatus = "preserved";
848
+ // Step 1: Acquire lock
849
+ if (!acquireInstanceLock(instanceId, "restoring")) {
850
+ const lock = getInstanceLockStatus(instanceId);
851
+ throw Object.assign(new Error(`Instance ${instanceId} is locked: ${lock?.operation}`), { statusCode: 409 });
852
+ }
853
+ const stopHeartbeat = startLockHeartbeat(instanceId);
854
+ const tmpDir = join(TMP_DIR, `restore-${instanceId}-${Date.now()}`);
855
+ try {
856
+ // Step 2: Safe extract to temp dir
857
+ mkdirSync(tmpDir, { recursive: true });
858
+ await safeExtract(backupFilePath, tmpDir);
859
+ touchInstanceLock(instanceId);
860
+ // Step 3: Resolve the archive root (handles both self-pack layout and
861
+ // official CLI's top-level wrapper directory) and validate manifest
862
+ const archiveRoot = resolveArchiveRoot(tmpDir);
863
+ const manifestPath = join(archiveRoot, "manifest.json");
864
+ if (!existsSync(manifestPath))
865
+ throw new Error("Archive missing manifest.json");
866
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
867
+ if (manifest.schemaVersion && manifest.schemaVersion > 1) {
868
+ warnings.push(`Archive schemaVersion ${manifest.schemaVersion} is newer than supported (1)`);
869
+ }
870
+ // Step 4: Locate .openclaw/ via payload/ directory
871
+ const payloadDir = join(archiveRoot, "payload");
872
+ if (!existsSync(payloadDir))
873
+ throw new Error("Archive missing payload/ directory");
874
+ const extractedStateDir = findStateDir(payloadDir);
875
+ if (!extractedStateDir)
876
+ throw new Error("Could not locate .openclaw directory in archive");
877
+ // extractedHomeDir is the parent of .openclaw — source for home-scope restore
878
+ const extractedHomeDir = dirname(extractedStateDir);
879
+ // Verify openclaw.json exists in located dir
880
+ const extractedConfigPath = join(extractedStateDir, "openclaw.json");
881
+ if (!existsSync(extractedConfigPath))
882
+ throw new Error("Archive missing openclaw.json");
883
+ JSON.parse(readFileSync(extractedConfigPath, "utf-8")); // verify parseable
884
+ // Determine effective scope and check arch compatibility
885
+ const archiveScope = manifest.scope === "home" ? "home" : "state";
886
+ let effectiveScope = archiveScope;
887
+ const platformMismatch = manifest.platform && manifest.platform !== process.platform;
888
+ const archMismatch = manifest.arch && manifest.arch !== process.arch;
889
+ if (platformMismatch || archMismatch) {
890
+ // Archive was built elsewhere — openclaw.json may reference API key env
891
+ // vars that this machine's provider.env doesn't have set.
892
+ apiKeyStatus = "lost";
893
+ }
894
+ if (archiveScope === "home" && (platformMismatch || archMismatch)) {
895
+ warnings.push(`Architecture mismatch: archive built for ${manifest.platform}/${manifest.arch}, ` +
896
+ `current host is ${process.platform}/${process.arch}. ` +
897
+ `Downgrading to state-only restore. You may need to re-run "openclaw update" after restore.`);
898
+ effectiveScope = "state";
899
+ }
900
+ touchInstanceLock(instanceId);
901
+ // Step 5: Create pre-restore backup at the SAME scope we're about to
902
+ // overwrite, so rollback can actually put everything back. If we're doing
903
+ // a home-scope restore (which clears .npm-global/, .codex/, outer files),
904
+ // the pre-restore must include those; otherwise rollback would only
905
+ // recover .openclaw/ and leave the rest in a half-deleted state.
906
+ try {
907
+ const preRestoreDir = getInstanceBackupDir(instanceId);
908
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 19);
909
+ const preRestorePath = join(preRestoreDir, `pre-restore-${timestamp}.tar.gz`);
910
+ await selfPackOfficialFormat(instanceId, preRestorePath, {
911
+ type: "pre-restore",
912
+ scope: effectiveScope,
913
+ includeSessions: true,
914
+ includeWorkspace: true,
915
+ });
916
+ }
917
+ catch (e) {
918
+ warnings.push(`Pre-restore backup failed (continuing anyway): ${e.message}`);
919
+ }
920
+ touchInstanceLock(instanceId);
921
+ // Step 6: Stop instance
922
+ try {
923
+ const svc = await getSvc();
924
+ await svc.stopInstance(instanceId);
925
+ }
926
+ catch {
927
+ // Instance may not be running -- ignore stop failures
928
+ }
929
+ // Clean proxy state
930
+ try {
931
+ const llmProxy = await import("./llm-proxy/index.js");
932
+ llmProxy.cleanupInstance(instanceId);
933
+ }
934
+ catch { /* ignore */ }
935
+ touchInstanceLock(instanceId);
936
+ // Step 7: Clear current target directory based on effective scope
937
+ const instanceDir = join(INSTANCES_DIR, instanceId);
938
+ const openclawHome = meta.openclaw_home || join(instanceDir, "openclaw-home");
939
+ const stateDir = join(openclawHome, ".openclaw");
940
+ const cacheKeepDirs = new Set([".npm", ".cache", ".node_compile_cache"]);
941
+ if (effectiveScope === "home") {
942
+ // Home scope: clear openclaw-home except cache dirs
943
+ if (existsSync(openclawHome)) {
944
+ for (const entry of readdirSync(openclawHome)) {
945
+ if (cacheKeepDirs.has(entry))
946
+ continue;
947
+ rmSync(join(openclawHome, entry), { recursive: true, force: true });
948
+ }
949
+ }
950
+ else {
951
+ mkdirSync(openclawHome, { recursive: true });
952
+ }
953
+ }
954
+ else {
955
+ // State scope: clear only .openclaw/, keep cache subdirs inside
956
+ const legacyConfig = join(openclawHome, "openclaw.json");
957
+ if (existsSync(legacyConfig))
958
+ rmSync(legacyConfig, { force: true });
959
+ if (existsSync(stateDir)) {
960
+ for (const entry of readdirSync(stateDir)) {
961
+ if (cacheKeepDirs.has(entry))
962
+ continue;
963
+ rmSync(join(stateDir, entry), { recursive: true, force: true });
964
+ }
965
+ }
966
+ else {
967
+ mkdirSync(stateDir, { recursive: true });
968
+ }
969
+ }
970
+ touchInstanceLock(instanceId);
971
+ // Step 8: Copy restored content based on effective scope. Uses the
972
+ // manual lstat walker (not fs.cpSync) so dangling/absolute symlinks
973
+ // survive the round-trip without ENOENT.
974
+ if (effectiveScope === "home") {
975
+ // Home scope: copy entire extracted home dir into openclawHome
976
+ copyTreeLstat(extractedHomeDir, openclawHome, () => true);
977
+ }
978
+ else {
979
+ // State scope: copy only .openclaw/
980
+ if (existsSync(extractedStateDir)) {
981
+ copyTreeLstat(extractedStateDir, stateDir, () => true);
982
+ }
983
+ }
984
+ touchInstanceLock(instanceId);
985
+ // Step 10: Rebuild runtime state
986
+ try {
987
+ const { bootstrapInstanceProxy } = await import("./llm-proxy/index.js");
988
+ await bootstrapInstanceProxy(instanceId);
989
+ }
990
+ catch (e) {
991
+ warnings.push(`Proxy bootstrap warning: ${e.message}`);
992
+ }
993
+ touchInstanceLock(instanceId);
994
+ // Step 11: Start instance
995
+ let started = false;
996
+ try {
997
+ const svc = await getSvc();
998
+ const result = await svc.startInstance(instanceId);
999
+ started = result.ok !== false;
1000
+ }
1001
+ catch { /* ignore start failures */ }
1002
+ touchInstanceLock(instanceId);
1003
+ // Step 12: Health check (30s timeout)
1004
+ if (started) {
1005
+ let healthy = false;
1006
+ const deadline = Date.now() + 30_000;
1007
+ while (Date.now() < deadline) {
1008
+ touchInstanceLock(instanceId);
1009
+ try {
1010
+ const svc = await getSvc();
1011
+ const status = await svc.getStatus(instanceId);
1012
+ if (status.status === "running") {
1013
+ healthy = true;
1014
+ break;
1015
+ }
1016
+ }
1017
+ catch { /* keep polling */ }
1018
+ await new Promise(r => setTimeout(r, 2000));
1019
+ }
1020
+ if (!healthy) {
1021
+ warnings.push("Health check failed after restore. Attempting auto-rollback...");
1022
+ try {
1023
+ await _rollbackFromPreRestore(instanceId, meta);
1024
+ warnings.push("Auto-rollback completed. Instance restored to pre-restore state.");
1025
+ return { ok: false, warnings, api_key_status: apiKeyStatus, rolled_back: true };
1026
+ }
1027
+ catch (e) {
1028
+ warnings.push(`Auto-rollback also failed: ${e.message}. Manual recovery needed.`);
1029
+ return { ok: false, warnings, api_key_status: apiKeyStatus, rolled_back: false };
1030
+ }
1031
+ }
1032
+ }
1033
+ return { ok: true, warnings, api_key_status: apiKeyStatus };
1034
+ }
1035
+ finally {
1036
+ stopHeartbeat();
1037
+ if (existsSync(tmpDir))
1038
+ rmSync(tmpDir, { recursive: true, force: true });
1039
+ releaseInstanceLock(instanceId);
1040
+ }
1041
+ }
1042
+ /**
1043
+ * Create a new instance from a backup file.
1044
+ * Does NOT copy model.env (new proxy token), provider.env (can't decrypt), or IM credentials.
1045
+ * Hard rule: provider.env is always ignored — no decrypt attempt, no migration.
1046
+ */
1047
+ export async function createFromBackup(backupFilePath, opts) {
1048
+ const warnings = [];
1049
+ const tmpDir = join(TMP_DIR, `create-from-backup-${opts.newId}-${Date.now()}`);
1050
+ try {
1051
+ // Extract and validate
1052
+ mkdirSync(tmpDir, { recursive: true });
1053
+ await safeExtract(backupFilePath, tmpDir);
1054
+ // Resolve root — handles both self-pack and official CLI wrapper layouts
1055
+ const archiveRoot = resolveArchiveRoot(tmpDir);
1056
+ // Detect archive scope — for home-scope archives we restore the full
1057
+ // openclaw-home tree, not just .openclaw (see importInstance for the
1058
+ // rationale).
1059
+ let archiveScope = "state";
1060
+ const manifestPath = join(archiveRoot, "manifest.json");
1061
+ if (existsSync(manifestPath)) {
1062
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1063
+ if (manifest.has_api_keys === false || !manifest.has_api_keys) {
1064
+ warnings.push("No API Key in this backup. You will need to configure one.");
1065
+ }
1066
+ if (manifest?.scope === "home")
1067
+ archiveScope = "home";
1068
+ }
1069
+ // Locate .openclaw/ via payload/ directory (official format)
1070
+ const payloadDir = join(archiveRoot, "payload");
1071
+ if (!existsSync(payloadDir))
1072
+ throw new Error("Archive missing payload/ directory");
1073
+ const extractedStateDir = findStateDir(payloadDir);
1074
+ if (!extractedStateDir)
1075
+ throw new Error("Could not locate .openclaw directory in archive");
1076
+ // Create the new instance via existing createInstance
1077
+ const { createInstance } = await import("./instance-manager.js");
1078
+ const newMeta = await createInstance(opts.newId, opts.newName, opts.newDescription || "");
1079
+ // Copy content to the new instance's openclaw-home. Manual lstat
1080
+ // walker preserves symlinks as opaque blobs (see pack-side notes).
1081
+ const newOpenclawHome = newMeta.openclaw_home || join(INSTANCES_DIR, opts.newId, "openclaw-home");
1082
+ const newStateDir = join(newOpenclawHome, ".openclaw");
1083
+ if (archiveScope === "home") {
1084
+ // Home scope: copy the entire extracted home tree.
1085
+ const extractedHomeDir = dirname(extractedStateDir);
1086
+ copyTreeLstat(extractedHomeDir, newOpenclawHome, (rel) => {
1087
+ if (!rel)
1088
+ return true;
1089
+ if (rel === ".openclaw/openclaw-weixin")
1090
+ return false;
1091
+ if (rel.startsWith(".openclaw/openclaw-weixin/"))
1092
+ return false;
1093
+ return true;
1094
+ });
1095
+ }
1096
+ else {
1097
+ // State scope: copy only .openclaw/.
1098
+ copyTreeLstat(extractedStateDir, newStateDir, (rel) => {
1099
+ if (!rel)
1100
+ return true;
1101
+ if (rel.startsWith("openclaw-weixin"))
1102
+ return false;
1103
+ return true;
1104
+ });
1105
+ }
1106
+ // Scrub channel credentials from copied openclaw.json (new instance should not inherit IM bindings)
1107
+ await scrubNewInstanceConfig(join(newStateDir, "openclaw.json"));
1108
+ // DO NOT copy model.env — bootstrapInstanceProxy will generate a new proxy token
1109
+ // DO NOT copy provider.env — encrypted with source machine's key, can't decrypt
1110
+ // DO NOT copy instance.json — already created by createInstance
1111
+ // Bootstrap proxy for new instance (generates new token)
1112
+ try {
1113
+ const { bootstrapInstanceProxy } = await import("./llm-proxy/index.js");
1114
+ await bootstrapInstanceProxy(opts.newId);
1115
+ }
1116
+ catch (e) {
1117
+ warnings.push(`Proxy bootstrap warning: ${e.message}`);
1118
+ }
1119
+ warnings.push("Instance created. Configure API Key before starting.");
1120
+ return { ok: true, instance_id: opts.newId, warnings };
1121
+ }
1122
+ finally {
1123
+ if (existsSync(tmpDir))
1124
+ rmSync(tmpDir, { recursive: true, force: true });
1125
+ }
1126
+ }
1127
+ /**
1128
+ * Internal rollback helper -- restores from the most recent pre-restore backup.
1129
+ * Does NOT re-acquire lock (caller already holds it).
1130
+ * Does NOT create another pre-restore backup.
1131
+ */
1132
+ async function _rollbackFromPreRestore(instanceId, meta) {
1133
+ const backupDir = join(BACKUPS_DIR, instanceId);
1134
+ if (!existsSync(backupDir))
1135
+ throw new Error("No backup directory for rollback");
1136
+ // Find most recent pre-restore backup
1137
+ const preRestores = readdirSync(backupDir)
1138
+ .filter(f => f.startsWith("pre-restore-") && f.endsWith(".tar.gz"))
1139
+ .sort()
1140
+ .reverse();
1141
+ if (preRestores.length === 0)
1142
+ throw new Error("No pre-restore backup found for rollback");
1143
+ const preRestorePath = join(backupDir, preRestores[0]);
1144
+ const tmpDir = join(TMP_DIR, `rollback-${instanceId}-${Date.now()}`);
1145
+ try {
1146
+ mkdirSync(tmpDir, { recursive: true });
1147
+ await safeExtract(preRestorePath, tmpDir);
1148
+ const openclawHome = meta.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
1149
+ const stateDir = join(openclawHome, ".openclaw");
1150
+ // Resolve archive root (pre-restore is always self-pack so this is
1151
+ // usually a no-op, but handle either layout for safety).
1152
+ const archiveRoot = resolveArchiveRoot(tmpDir);
1153
+ // Locate .openclaw/ and its parent (source for home-scope rollback)
1154
+ const payloadDir = join(archiveRoot, "payload");
1155
+ const extractedStateDir = existsSync(payloadDir) ? findStateDir(payloadDir) : null;
1156
+ if (!extractedStateDir)
1157
+ throw new Error("pre-restore archive missing .openclaw directory");
1158
+ const extractedHomeDir = dirname(extractedStateDir);
1159
+ // Read the pre-restore's own manifest to decide rollback scope.
1160
+ // If the pre-restore was packed as home scope, we must rollback the full
1161
+ // home tree (.npm-global/, .codex/, outer openclaw.json) — not just
1162
+ // .openclaw/ — otherwise we leave the instance in a half-deleted state.
1163
+ let rollbackScope = "state";
1164
+ const manifestPath = join(archiveRoot, "manifest.json");
1165
+ if (existsSync(manifestPath)) {
1166
+ try {
1167
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1168
+ if (manifest?.scope === "home")
1169
+ rollbackScope = "home";
1170
+ }
1171
+ catch { /* fall back to state */ }
1172
+ }
1173
+ const cacheKeepDirs = new Set([".npm", ".cache", ".node_compile_cache"]);
1174
+ if (rollbackScope === "home") {
1175
+ // Clear openclaw-home/ except cache dirs, then restore the entire tree.
1176
+ if (existsSync(openclawHome)) {
1177
+ for (const entry of readdirSync(openclawHome)) {
1178
+ if (cacheKeepDirs.has(entry))
1179
+ continue;
1180
+ rmSync(join(openclawHome, entry), { recursive: true, force: true });
1181
+ }
1182
+ }
1183
+ else {
1184
+ mkdirSync(openclawHome, { recursive: true });
1185
+ }
1186
+ copyTreeLstat(extractedHomeDir, openclawHome, () => true);
1187
+ }
1188
+ else {
1189
+ // State-scope rollback: clear and restore only .openclaw/.
1190
+ if (existsSync(stateDir)) {
1191
+ for (const entry of readdirSync(stateDir)) {
1192
+ if (cacheKeepDirs.has(entry))
1193
+ continue;
1194
+ rmSync(join(stateDir, entry), { recursive: true, force: true });
1195
+ }
1196
+ }
1197
+ else {
1198
+ mkdirSync(stateDir, { recursive: true });
1199
+ }
1200
+ copyTreeLstat(extractedStateDir, stateDir, () => true);
1201
+ }
1202
+ // Rebuild proxy state
1203
+ try {
1204
+ const { bootstrapInstanceProxy } = await import("./llm-proxy/index.js");
1205
+ await bootstrapInstanceProxy(instanceId);
1206
+ }
1207
+ catch { /* best effort */ }
1208
+ // Try to restart
1209
+ try {
1210
+ const svc = await getSvc();
1211
+ await svc.startInstance(instanceId);
1212
+ }
1213
+ catch { /* best effort */ }
1214
+ }
1215
+ finally {
1216
+ if (existsSync(tmpDir))
1217
+ rmSync(tmpDir, { recursive: true, force: true });
1218
+ }
1219
+ }
1220
+ // ── Export ──
1221
+ /**
1222
+ * Export an instance for sharing. Uses WHITELIST strategy — only safe directories included.
1223
+ * API keys and credentials are stripped. Sensitive fields in openclaw.json are scrubbed.
1224
+ */
1225
+ export async function exportInstance(instanceId, opts = {}) {
1226
+ const { getInstance, getOpenclawHome } = await import("./instance-manager.js");
1227
+ const meta = getInstance(instanceId);
1228
+ if (!meta)
1229
+ throw new Error(`Instance ${instanceId} not found`);
1230
+ const openclawHome = getOpenclawHome(instanceId);
1231
+ const stateDir = join(openclawHome, ".openclaw");
1232
+ if (!existsSync(stateDir))
1233
+ throw new Error(`State directory not found: ${stateDir}`);
1234
+ if (!acquireInstanceLock(instanceId, "exporting")) {
1235
+ throw Object.assign(new Error(`Instance ${instanceId} is locked`), { statusCode: 409 });
1236
+ }
1237
+ const stopHeartbeat = startLockHeartbeat(instanceId);
1238
+ // Export downloads are served per-instance from TMP_DIR/exports/<instanceId>/.
1239
+ // The download route enforces this isolation so one instance's URL can't
1240
+ // reach another's file.
1241
+ const instanceExportDir = getInstanceExportDir(instanceId);
1242
+ const outputPath = opts.outputPath || join(instanceExportDir, `export-${instanceId}-${Date.now()}.tar.gz`);
1243
+ const stagingDir = join(TMP_DIR, `export-staging-${instanceId}-${Date.now()}`);
1244
+ const tmpBackupPath = join(TMP_DIR, `export-raw-${instanceId}-${Date.now()}.tar.gz`);
1245
+ try {
1246
+ // Step 1: Self-pack in official format (always self-pack for export — we need to filter)
1247
+ await selfPackOfficialFormat(instanceId, tmpBackupPath, {
1248
+ type: "export",
1249
+ includeSessions: opts.includeSessions,
1250
+ includeWorkspace: true,
1251
+ });
1252
+ // Step 2: Extract to staging
1253
+ mkdirSync(stagingDir, { recursive: true });
1254
+ await safeExtract(tmpBackupPath, stagingDir);
1255
+ if (existsSync(tmpBackupPath))
1256
+ rmSync(tmpBackupPath, { force: true });
1257
+ // Step 3: Resolve archive root + locate .openclaw/ via payload/.
1258
+ // self-pack always writes at root so resolveArchiveRoot is a no-op here,
1259
+ // but using it keeps all consumers on one canonical path.
1260
+ const stagingRoot = resolveArchiveRoot(stagingDir);
1261
+ const payloadDir = join(stagingRoot, "payload");
1262
+ if (!existsSync(payloadDir))
1263
+ throw new Error("Self-pack missing payload/ directory");
1264
+ const foundDir = findStateDir(payloadDir);
1265
+ if (!foundDir)
1266
+ throw new Error("Could not locate .openclaw in self-pack");
1267
+ // Step 4: Whitelist filter — remove everything outside allowed dirs
1268
+ const allowedRoots = ["openclaw.json", "extensions", "workspace"];
1269
+ if (opts.includeSessions)
1270
+ allowedRoots.push("agents");
1271
+ for (const entry of readdirSync(foundDir)) {
1272
+ if (!allowedRoots.includes(entry)) {
1273
+ rmSync(join(foundDir, entry), { recursive: true, force: true });
1274
+ }
1275
+ }
1276
+ // Step 5: Scrub openclaw.json
1277
+ const configPath = join(foundDir, "openclaw.json");
1278
+ const scrubWarnings = [];
1279
+ if (existsSync(configPath)) {
1280
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
1281
+ // Scrub provider API keys
1282
+ const providers = config?.models?.providers;
1283
+ if (providers) {
1284
+ for (const [pid, prov] of Object.entries(providers)) {
1285
+ if (prov?.apiKey)
1286
+ prov.apiKey = "";
1287
+ // Remove proxy providers
1288
+ if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
1289
+ delete providers[pid];
1290
+ }
1291
+ }
1292
+ }
1293
+ // Scrub channel credentials
1294
+ const channels = config?.channels;
1295
+ if (channels) {
1296
+ for (const [, ch] of Object.entries(channels)) {
1297
+ if (ch?.appSecret)
1298
+ ch.appSecret = "";
1299
+ if (ch?.appId)
1300
+ ch.appId = "";
1301
+ if (ch?.token)
1302
+ ch.token = "";
1303
+ if (ch?.secret)
1304
+ ch.secret = "";
1305
+ if (ch?.credentials)
1306
+ ch.credentials = {};
1307
+ }
1308
+ }
1309
+ // Scrub gateway control-UI token — this is the token the panel uses
1310
+ // to drive OpenClaw's gateway control API; leaking it would let anyone
1311
+ // with the export package call the recipient's gateway once imported.
1312
+ if (config?.gateway?.auth?.token)
1313
+ config.gateway.auth.token = "";
1314
+ // Remove proxy model reference
1315
+ const defaultModel = config?.agents?.defaults?.model;
1316
+ if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
1317
+ delete config.agents.defaults.model;
1318
+ }
1319
+ // Residual scan — walk the object and flag any string-valued field
1320
+ // whose key name looks sensitive. The warning only records the JSON
1321
+ // path and field length, NEVER the value itself, so a residual field
1322
+ // can't leak through manifest.warnings.
1323
+ const SENSITIVE_KEY_RE = /^(api_?key|token|secret|credential|password|auth_?key|app_?secret|app_?id)$/i;
1324
+ const walk = (node, path) => {
1325
+ if (!node || typeof node !== "object")
1326
+ return;
1327
+ for (const [key, val] of Object.entries(node)) {
1328
+ const here = path ? `${path}.${key}` : key;
1329
+ if (typeof val === "string" && val.length >= 4 && SENSITIVE_KEY_RE.test(key)) {
1330
+ scrubWarnings.push(`Possible residual sensitive field at ${here} (${val.length} chars)`);
1331
+ }
1332
+ else if (val && typeof val === "object") {
1333
+ walk(val, here);
1334
+ }
1335
+ }
1336
+ };
1337
+ walk(config, "");
1338
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
1339
+ }
1340
+ // Step 6: Update manifest
1341
+ const manifestPath = join(stagingDir, "manifest.json");
1342
+ const manifest = existsSync(manifestPath)
1343
+ ? JSON.parse(readFileSync(manifestPath, "utf-8"))
1344
+ : { schemaVersion: 1 };
1345
+ manifest.type = "export";
1346
+ manifest.has_api_keys = false;
1347
+ manifest.has_sessions = opts.includeSessions ?? false;
1348
+ if (scrubWarnings.length > 0)
1349
+ manifest.warnings = scrubWarnings;
1350
+ // Recalculate checksum
1351
+ const allFiles = collectFiles(stagingDir, stagingDir, []);
1352
+ const nonManifest = allFiles.filter(f => f !== "manifest.json");
1353
+ const checksum = calculateContentChecksum(stagingDir, nonManifest);
1354
+ manifest.checksum = `sha256:${checksum}`;
1355
+ manifest.checksum_scope = "content-excluding-manifest";
1356
+ manifest.stats = { total_files: allFiles.length };
1357
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1358
+ // Step 7: Repack
1359
+ touchInstanceLock(instanceId);
1360
+ await new Promise((resolve, reject) => {
1361
+ const child = spawnChild("tar", ["-czf", outputPath, "."], {
1362
+ cwd: stagingDir,
1363
+ stdio: ["ignore", "pipe", "pipe"],
1364
+ });
1365
+ let stderr = "";
1366
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
1367
+ child.on("close", (code) => {
1368
+ if (code !== 0)
1369
+ reject(new Error(`tar pack failed (exit ${code}): ${stderr.slice(0, 500)}`));
1370
+ else
1371
+ resolve();
1372
+ });
1373
+ child.on("error", reject);
1374
+ });
1375
+ const finalSize = statSync(outputPath).size;
1376
+ return { filename: outputPath.split("/").pop(), filepath: outputPath, size: finalSize, manifest };
1377
+ }
1378
+ finally {
1379
+ stopHeartbeat();
1380
+ if (existsSync(stagingDir))
1381
+ rmSync(stagingDir, { recursive: true, force: true });
1382
+ if (existsSync(tmpBackupPath))
1383
+ rmSync(tmpBackupPath, { force: true });
1384
+ releaseInstanceLock(instanceId);
1385
+ }
1386
+ }
1387
+ // ── Import (three-step) ──
1388
+ /** Step 1: Store uploaded file to tmp. Returns temp_id for subsequent calls. */
1389
+ export async function storeUpload(filePath) {
1390
+ const tempId = randomUUID();
1391
+ const destPath = join(TMP_DIR, `import-${tempId}.tar.gz`);
1392
+ ensureBackupDirs();
1393
+ copyFileSync(filePath, destPath);
1394
+ return { temp_id: tempId };
1395
+ }
1396
+ /** Step 2: Preview an uploaded archive without creating an instance. */
1397
+ export async function previewImport(tempId) {
1398
+ const archivePath = join(TMP_DIR, `import-${tempId}.tar.gz`);
1399
+ if (!existsSync(archivePath))
1400
+ throw new Error("Upload not found or expired");
1401
+ const tmpDir = join(TMP_DIR, `preview-${tempId}`);
1402
+ const warnings = [];
1403
+ try {
1404
+ mkdirSync(tmpDir, { recursive: true });
1405
+ const scanResult = await safeExtract(archivePath, tmpDir);
1406
+ // Resolve archive root — handles both self-pack and CLI wrapper layouts
1407
+ const archiveRoot = resolveArchiveRoot(tmpDir);
1408
+ // Read manifest
1409
+ const manifestPath = join(archiveRoot, "manifest.json");
1410
+ let manifest = null;
1411
+ if (existsSync(manifestPath)) {
1412
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1413
+ }
1414
+ // Detect format — all archives use the official payload/ layout
1415
+ let format = "unknown";
1416
+ if (existsSync(join(archiveRoot, "payload"))) {
1417
+ format = "official";
1418
+ }
1419
+ // Check for missing API keys
1420
+ if (manifest?.has_api_keys === false) {
1421
+ warnings.push("This package does not contain API keys. You will need to configure them.");
1422
+ }
1423
+ // Check sessions
1424
+ if (manifest?.has_sessions === false) {
1425
+ warnings.push("This package does not contain conversation history.");
1426
+ }
1427
+ // Version compatibility
1428
+ if (manifest?.runtimeVersion) {
1429
+ warnings.push(`Created with OpenClaw ${manifest.runtimeVersion}`);
1430
+ }
1431
+ return {
1432
+ manifest,
1433
+ warnings,
1434
+ estimated_size: scanResult.totalSize,
1435
+ format,
1436
+ };
1437
+ }
1438
+ finally {
1439
+ if (existsSync(tmpDir))
1440
+ rmSync(tmpDir, { recursive: true, force: true });
1441
+ }
1442
+ }
1443
+ /** Step 3: Create a new instance from a previously uploaded archive. */
1444
+ export async function importInstance(tempId, opts) {
1445
+ const archivePath = join(TMP_DIR, `import-${tempId}.tar.gz`);
1446
+ if (!existsSync(archivePath))
1447
+ throw new Error("Upload not found or expired");
1448
+ const warnings = [];
1449
+ const tmpDir = join(TMP_DIR, `import-create-${tempId}`);
1450
+ try {
1451
+ mkdirSync(tmpDir, { recursive: true });
1452
+ await safeExtract(archivePath, tmpDir);
1453
+ // Resolve archive root — handles both self-pack and CLI wrapper layouts
1454
+ const archiveRoot = resolveArchiveRoot(tmpDir);
1455
+ // Locate .openclaw/ via payload/ directory (official format)
1456
+ const payloadDir = join(archiveRoot, "payload");
1457
+ if (!existsSync(payloadDir))
1458
+ throw new Error("Archive missing payload/ directory");
1459
+ const extractedStateDir = findStateDir(payloadDir);
1460
+ if (!extractedStateDir)
1461
+ throw new Error("Could not locate .openclaw directory in archive");
1462
+ // Detect archive scope — for home-scope archives we restore the full
1463
+ // openclaw-home tree (app/, .npm-global/, .codex/, etc.), not just
1464
+ // the .openclaw state dir. Without this, importing a home-scope
1465
+ // backup silently drops the runtime/app tree that motivated the
1466
+ // home-scope in the first place.
1467
+ let archiveScope = "state";
1468
+ const manifestPath = join(archiveRoot, "manifest.json");
1469
+ if (existsSync(manifestPath)) {
1470
+ try {
1471
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1472
+ if (manifest?.scope === "home")
1473
+ archiveScope = "home";
1474
+ }
1475
+ catch { /* fall back to state */ }
1476
+ }
1477
+ // Create new instance
1478
+ const { createInstance } = await import("./instance-manager.js");
1479
+ const newMeta = await createInstance(opts.id, opts.name, opts.description || "");
1480
+ const newOpenclawHome = newMeta.openclaw_home || join(INSTANCES_DIR, opts.id, "openclaw-home");
1481
+ const newStateDir = join(newOpenclawHome, ".openclaw");
1482
+ // Manual lstat walker preserves symlinks as opaque blobs and never
1483
+ // follows dangling targets.
1484
+ if (archiveScope === "home") {
1485
+ // Home scope: copy the full extracted home tree so app/,
1486
+ // .npm-global/ (upgraded runtime), .codex/, and outer files survive.
1487
+ // Still exclude IM credentials — same channel can't bind multiple
1488
+ // instances.
1489
+ const extractedHomeDir = dirname(extractedStateDir);
1490
+ copyTreeLstat(extractedHomeDir, newOpenclawHome, (rel) => {
1491
+ if (!rel)
1492
+ return true;
1493
+ if (rel === ".openclaw/openclaw-weixin")
1494
+ return false;
1495
+ if (rel.startsWith(".openclaw/openclaw-weixin/"))
1496
+ return false;
1497
+ return true;
1498
+ });
1499
+ }
1500
+ else {
1501
+ // State scope: copy only .openclaw/.
1502
+ copyTreeLstat(extractedStateDir, newStateDir, (rel) => {
1503
+ if (!rel)
1504
+ return true;
1505
+ if (rel.startsWith("openclaw-weixin"))
1506
+ return false;
1507
+ return true;
1508
+ });
1509
+ }
1510
+ // Scrub channel credentials from copied openclaw.json (new instance should not inherit IM bindings)
1511
+ await scrubNewInstanceConfig(join(newStateDir, "openclaw.json"));
1512
+ // DO NOT copy model.env, provider.env, instance.json
1513
+ // Bootstrap proxy
1514
+ try {
1515
+ const { bootstrapInstanceProxy } = await import("./llm-proxy/index.js");
1516
+ await bootstrapInstanceProxy(opts.id);
1517
+ }
1518
+ catch (e) {
1519
+ warnings.push(`Proxy bootstrap: ${e.message}`);
1520
+ }
1521
+ // Check if default_provider can be injected
1522
+ try {
1523
+ const { getPanelConfig } = await import("../config.js");
1524
+ const panelConfig = getPanelConfig();
1525
+ if (panelConfig?.default_provider?.apiKey && !panelConfig.default_provider.skipped) {
1526
+ warnings.push("Default provider detected. You may configure API Key from the config page.");
1527
+ }
1528
+ }
1529
+ catch { /* ignore */ }
1530
+ warnings.push("Instance created. Configure API Key before starting.");
1531
+ // Clean up the upload file
1532
+ rmSync(archivePath, { force: true });
1533
+ return { ok: true, instance_id: opts.id, warnings };
1534
+ }
1535
+ finally {
1536
+ if (existsSync(tmpDir))
1537
+ rmSync(tmpDir, { recursive: true, force: true });
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Scrub proxy identity and IM bindings from a copied openclaw.json before it
1542
+ * is handed to a brand-new instance (importInstance / createFromBackup).
1543
+ *
1544
+ * Matches the domain-clone behavior in `createInstance` so both paths produce
1545
+ * identical "clean slate" configs: no inherited proxy provider, no inherited
1546
+ * channel bindings, no dangling IM plugin entries. This avoids the hazard
1547
+ * where imported configs kept `channels.*.enabled = true` with scrubbed
1548
+ * credentials, leaving the plugin loader to boot a half-configured binding.
1549
+ *
1550
+ * **Important**: JishuShell double-writes openclaw.json to both the runtime
1551
+ * path (`.openclaw/openclaw.json`) and the legacy alias
1552
+ * (`openclaw-home/openclaw.json`). `loadEffectiveConfig` deep-merges both
1553
+ * when read back, so scrubbing only the runtime path leaves the legacy copy
1554
+ * as a ghost writer that resurrects deleted fields on the next saveConfig.
1555
+ * Both paths must be scrubbed — or kept in sync — before any subsequent
1556
+ * read. This function accepts the runtime path and scrubs the sibling
1557
+ * legacy path too when present.
1558
+ */
1559
+ async function scrubNewInstanceConfig(configPath) {
1560
+ const { stripImBindings } = await import("./instance-manager.js");
1561
+ const scrubOne = (path) => {
1562
+ if (!existsSync(path))
1563
+ return;
1564
+ try {
1565
+ const config = JSON.parse(readFileSync(path, "utf-8"));
1566
+ // Remove proxy providers (will be regenerated by bootstrapInstanceProxy)
1567
+ const providers = config?.models?.providers;
1568
+ if (providers) {
1569
+ for (const [pid, prov] of Object.entries(providers)) {
1570
+ if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
1571
+ delete providers[pid];
1572
+ }
1573
+ }
1574
+ }
1575
+ // Remove proxy model reference from agent defaults
1576
+ const defaultModel = config?.agents?.defaults?.model;
1577
+ if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
1578
+ delete config.agents.defaults.model;
1579
+ }
1580
+ // Unified IM scrub: delete channels block + matching plugin entries
1581
+ stripImBindings(config);
1582
+ writeFileSync(path, JSON.stringify(config, null, 2));
1583
+ }
1584
+ catch { /* best effort */ }
1585
+ };
1586
+ scrubOne(configPath);
1587
+ // Also scrub the legacy outer-home alias if it exists. The runtime path
1588
+ // is .openclaw/openclaw.json and the legacy path is the parent dir's
1589
+ // openclaw.json (i.e. openclaw-home/openclaw.json).
1590
+ try {
1591
+ const runtimeDir = dirname(configPath); // .../openclaw-home/.openclaw
1592
+ const homeDir = dirname(runtimeDir); // .../openclaw-home
1593
+ const legacyPath = join(homeDir, "openclaw.json");
1594
+ if (legacyPath !== configPath)
1595
+ scrubOne(legacyPath);
1596
+ }
1597
+ catch { /* best effort */ }
1598
+ }
1599
+ /**
1600
+ * Resolve the effective "archive root" inside an extracted tarball.
1601
+ *
1602
+ * Returns the directory where `manifest.json` and `payload/` live. Two
1603
+ * archive layouts are supported:
1604
+ *
1605
+ * 1. **JishuShell self-pack** (`selfPackOfficialFormat`): writes
1606
+ * `manifest.json` and `payload/` directly at the archive root, so the
1607
+ * effective root is the extraction dir itself.
1608
+ *
1609
+ * 2. **Official OpenClaw `backup create`**: wraps everything inside a
1610
+ * single top-level directory named after the archive basename, e.g.
1611
+ * `2026-04-10T03-06-23.257Z-openclaw-backup/`. The wrapper name is
1612
+ * also advertised via `manifest.archiveRoot`.
1613
+ *
1614
+ * Detection strategy (cheapest first):
1615
+ * a. If `<tmpDir>/manifest.json` exists → self-pack → return tmpDir.
1616
+ * b. Else scan top-level entries; if exactly one is a directory containing
1617
+ * `manifest.json`, that's the official CLI wrapper → return it.
1618
+ * c. Fall back to tmpDir; the caller will throw its own missing-manifest
1619
+ * error with a meaningful path.
1620
+ *
1621
+ * Without this helper, `restoreInstance` / `importInstance` / `verifyArchive`
1622
+ * would throw "Archive missing manifest.json" on every state-scope archive
1623
+ * produced by the official CLI.
1624
+ */
1625
+ export function resolveArchiveRoot(tmpDir) {
1626
+ if (existsSync(join(tmpDir, "manifest.json")))
1627
+ return tmpDir;
1628
+ try {
1629
+ for (const entry of readdirSync(tmpDir)) {
1630
+ const candidate = join(tmpDir, entry);
1631
+ try {
1632
+ if (statSync(candidate).isDirectory() && existsSync(join(candidate, "manifest.json"))) {
1633
+ return candidate;
1634
+ }
1635
+ }
1636
+ catch { /* skip inaccessible */ }
1637
+ }
1638
+ }
1639
+ catch { /* skip */ }
1640
+ return tmpDir;
1641
+ }
1642
+ /**
1643
+ * Locate the OpenClaw state directory inside an extracted archive.
1644
+ *
1645
+ * Finds a directory **literally named `.openclaw`** that contains an
1646
+ * `openclaw.json` file. Uses BFS so the shallowest match wins, which guards
1647
+ * against nested copies inside extensions/ or workspace/.
1648
+ *
1649
+ * This is intentionally stricter than "any directory containing
1650
+ * openclaw.json". JishuShell's `saveConfig` double-writes the config to both
1651
+ * `.openclaw/openclaw.json` and an outer legacy alias at
1652
+ * `openclaw-home/openclaw.json`, so a naive "first dir with openclaw.json"
1653
+ * search would incorrectly pick the `openclaw-home/` root for home-scope
1654
+ * archives.
1655
+ */
1656
+ export function findStateDir(rootDir) {
1657
+ const queue = [rootDir];
1658
+ while (queue.length > 0) {
1659
+ const current = queue.shift();
1660
+ try {
1661
+ for (const entry of readdirSync(current)) {
1662
+ const fullPath = join(current, entry);
1663
+ try {
1664
+ if (!statSync(fullPath).isDirectory())
1665
+ continue;
1666
+ if (entry === ".openclaw" && existsSync(join(fullPath, "openclaw.json"))) {
1667
+ return fullPath;
1668
+ }
1669
+ queue.push(fullPath);
1670
+ }
1671
+ catch { /* skip inaccessible entries */ }
1672
+ }
1673
+ }
1674
+ catch { /* skip inaccessible dirs */ }
1675
+ }
1676
+ return null;
1677
+ }
1678
+ // ── Auto-backup scheduler ──
1679
+ const autoBackupTimers = new Map();
1680
+ /** Get auto-backup config from instance.json */
1681
+ export async function getAutoBackupConfig(instanceId) {
1682
+ try {
1683
+ const { getInstance } = await import("./instance-manager.js");
1684
+ const meta = getInstance(instanceId);
1685
+ return meta?.auto_backup || null;
1686
+ }
1687
+ catch {
1688
+ return null;
1689
+ }
1690
+ }
1691
+ /** Update auto-backup status in instance.json */
1692
+ async function updateAutoBackupStatus(instanceId, patch) {
1693
+ const { updateInstanceMeta, getInstance } = await import("./instance-manager.js");
1694
+ const meta = getInstance(instanceId);
1695
+ const current = meta?.auto_backup || {};
1696
+ updateInstanceMeta(instanceId, { auto_backup: { ...current, ...patch } });
1697
+ }
1698
+ /** Check if instance has changed since last backup (mtime-based) */
1699
+ async function hasChangedSince(instanceId, sinceMs) {
1700
+ try {
1701
+ const { getInstance } = await import("./instance-manager.js");
1702
+ const meta = getInstance(instanceId);
1703
+ const openclawHome = meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
1704
+ const stateDir = join(openclawHome, ".openclaw");
1705
+ if (!existsSync(stateDir))
1706
+ return false;
1707
+ return getMaxMtime(stateDir, 0) > sinceMs;
1708
+ }
1709
+ catch {
1710
+ return true; // On error, assume changed (safe default)
1711
+ }
1712
+ }
1713
+ /** Recursively find max mtime in a directory, skipping cache dirs */
1714
+ function getMaxMtime(dir, currentMax) {
1715
+ const skipDirs = new Set([".npm", ".cache", ".node_compile_cache", ".npm-global"]);
1716
+ try {
1717
+ for (const entry of readdirSync(dir)) {
1718
+ if (skipDirs.has(entry))
1719
+ continue;
1720
+ const fullPath = join(dir, entry);
1721
+ try {
1722
+ const stat = statSync(fullPath);
1723
+ if (stat.mtimeMs > currentMax)
1724
+ currentMax = stat.mtimeMs;
1725
+ if (stat.isDirectory()) {
1726
+ currentMax = getMaxMtime(fullPath, currentMax);
1727
+ }
1728
+ }
1729
+ catch { /* skip inaccessible */ }
1730
+ }
1731
+ }
1732
+ catch { /* skip */ }
1733
+ return currentMax;
1734
+ }
1735
+ /** Clean old auto-backup files, keeping only the newest `keepCount` */
1736
+ export async function cleanOldAutoBackups(instanceId, keepCount) {
1737
+ const backupDir = join(BACKUPS_DIR, instanceId);
1738
+ if (!existsSync(backupDir))
1739
+ return 0;
1740
+ const autoBackups = readdirSync(backupDir)
1741
+ .filter(f => f.startsWith("auto-backup-") && f.endsWith(".tar.gz"))
1742
+ .map(f => ({ name: f, mtime: statSync(join(backupDir, f)).mtimeMs }))
1743
+ .sort((a, b) => b.mtime - a.mtime); // newest first
1744
+ let cleaned = 0;
1745
+ for (let i = keepCount; i < autoBackups.length; i++) {
1746
+ rmSync(join(backupDir, autoBackups[i].name), { force: true });
1747
+ cleaned++;
1748
+ }
1749
+ return cleaned;
1750
+ }
1751
+ /** Check available disk space (returns bytes) */
1752
+ function getAvailableDiskSpace() {
1753
+ try {
1754
+ const output = execFileSync("df", ["-B1", "--output=avail", BACKUPS_DIR], {
1755
+ encoding: "utf-8",
1756
+ timeout: 5000,
1757
+ });
1758
+ const lines = output.trim().split("\n");
1759
+ return parseInt(lines[lines.length - 1].trim(), 10) || 0;
1760
+ }
1761
+ catch {
1762
+ try {
1763
+ // macOS fallback
1764
+ const output = execFileSync("df", ["-k", BACKUPS_DIR], { encoding: "utf-8", timeout: 5000 });
1765
+ const lines = output.trim().split("\n");
1766
+ const parts = lines[lines.length - 1].split(/\s+/);
1767
+ return (parseInt(parts[3], 10) || 0) * 1024; // Convert KB to bytes
1768
+ }
1769
+ catch {
1770
+ return Infinity; // Can't check, don't block
1771
+ }
1772
+ }
1773
+ }
1774
+ const MIN_DISK_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
1775
+ const jobQueue = [];
1776
+ const jobHistory = [];
1777
+ const MAX_HISTORY = 20;
1778
+ let currentJob = null;
1779
+ let queueProcessing = false;
1780
+ // Map of job ID to resolve/reject for callers that want to await completion
1781
+ const jobWaiters = new Map();
1782
+ // Store execute functions for queued jobs
1783
+ const jobExecutors = new Map();
1784
+ /** Enqueue a backup operation. Returns the job immediately (non-blocking). */
1785
+ export function enqueueJob(instanceId, operation, executeFn) {
1786
+ const job = {
1787
+ id: randomUUID(),
1788
+ instanceId,
1789
+ operation,
1790
+ status: "queued",
1791
+ createdAt: Date.now(),
1792
+ };
1793
+ jobQueue.push(job);
1794
+ // Store the execute function for when this job's turn comes
1795
+ jobExecutors.set(job.id, executeFn);
1796
+ // Start processing if not already running
1797
+ if (!queueProcessing) {
1798
+ processQueue();
1799
+ }
1800
+ return job;
1801
+ }
1802
+ /** Enqueue and wait for completion. Returns the completed job. */
1803
+ export function enqueueJobAndWait(instanceId, operation, executeFn) {
1804
+ const job = enqueueJob(instanceId, operation, executeFn);
1805
+ return new Promise((resolve, reject) => {
1806
+ jobWaiters.set(job.id, { resolve, reject });
1807
+ });
1808
+ }
1809
+ async function processQueue() {
1810
+ if (queueProcessing)
1811
+ return;
1812
+ queueProcessing = true;
1813
+ while (jobQueue.length > 0) {
1814
+ const job = jobQueue.shift();
1815
+ currentJob = job;
1816
+ job.status = "running";
1817
+ job.startedAt = Date.now();
1818
+ // Get the executor function
1819
+ const executeFn = jobExecutors.get(job.id);
1820
+ jobExecutors.delete(job.id);
1821
+ if (!executeFn) {
1822
+ job.status = "failed";
1823
+ job.error = "No executor function found";
1824
+ job.completedAt = Date.now();
1825
+ moveToHistory(job);
1826
+ continue;
1827
+ }
1828
+ try {
1829
+ const result = await executeFn(job);
1830
+ job.status = "completed";
1831
+ job.result = result;
1832
+ }
1833
+ catch (e) {
1834
+ job.status = "failed";
1835
+ job.error = e.message || "Unknown error";
1836
+ }
1837
+ finally {
1838
+ job.completedAt = Date.now();
1839
+ currentJob = null;
1840
+ moveToHistory(job);
1841
+ // Notify waiter if any
1842
+ const waiter = jobWaiters.get(job.id);
1843
+ if (waiter) {
1844
+ jobWaiters.delete(job.id);
1845
+ if (job.status === "completed") {
1846
+ waiter.resolve(job);
1847
+ }
1848
+ else {
1849
+ waiter.reject(new Error(job.error || "Job failed"));
1850
+ }
1851
+ }
1852
+ }
1853
+ }
1854
+ queueProcessing = false;
1855
+ }
1856
+ function moveToHistory(job) {
1857
+ jobHistory.unshift(job);
1858
+ if (jobHistory.length > MAX_HISTORY)
1859
+ jobHistory.pop();
1860
+ }
1861
+ /** Update progress message for the currently running job */
1862
+ export function updateJobProgress(jobId, progress) {
1863
+ if (currentJob?.id === jobId) {
1864
+ currentJob.progress = progress;
1865
+ }
1866
+ }
1867
+ /** Get full queue status */
1868
+ export function getQueueStatus() {
1869
+ return {
1870
+ current: currentJob,
1871
+ queued: [...jobQueue],
1872
+ recent: jobHistory.slice(0, 10),
1873
+ };
1874
+ }
1875
+ /** Get a specific job by ID (checks current, queue, and history) */
1876
+ export function getJob(jobId) {
1877
+ if (currentJob?.id === jobId)
1878
+ return currentJob;
1879
+ const queued = jobQueue.find(j => j.id === jobId);
1880
+ if (queued)
1881
+ return queued;
1882
+ const historic = jobHistory.find(j => j.id === jobId);
1883
+ return historic || null;
1884
+ }
1885
+ /** Cancel a queued job (cannot cancel running jobs) */
1886
+ export function cancelJob(jobId) {
1887
+ const idx = jobQueue.findIndex(j => j.id === jobId);
1888
+ if (idx === -1)
1889
+ return false;
1890
+ const job = jobQueue.splice(idx, 1)[0];
1891
+ job.status = "failed";
1892
+ job.error = "Cancelled";
1893
+ job.completedAt = Date.now();
1894
+ moveToHistory(job);
1895
+ jobExecutors.delete(jobId);
1896
+ const waiter = jobWaiters.get(jobId);
1897
+ if (waiter) {
1898
+ jobWaiters.delete(jobId);
1899
+ waiter.reject(new Error("Job cancelled"));
1900
+ }
1901
+ return true;
1902
+ }
1903
+ /** Schedule auto-backup for an instance */
1904
+ export function scheduleAutoBackup(instanceId, config) {
1905
+ cancelAutoBackup(instanceId);
1906
+ if (!config.enabled)
1907
+ return;
1908
+ const lastAt = config.last_backup_at ? new Date(config.last_backup_at).getTime() : 0;
1909
+ const intervalMs = config.interval_hours * 3600_000;
1910
+ const delay = Math.max(0, intervalMs - (Date.now() - lastAt));
1911
+ const timer = setTimeout(async () => {
1912
+ // Always work against the freshest persisted config so we don't race
1913
+ // against user edits or updateAutoBackupStatus writes. The `config` arg
1914
+ // is only used for scheduling delay above.
1915
+ const liveConfig = (await getAutoBackupConfig(instanceId)) || config;
1916
+ const liveLastAt = liveConfig.last_backup_at
1917
+ ? new Date(liveConfig.last_backup_at).getTime()
1918
+ : 0;
1919
+ const keepCount = liveConfig.keep_count || 7;
1920
+ try {
1921
+ // Check disk space
1922
+ const available = getAvailableDiskSpace();
1923
+ if (available < MIN_DISK_BYTES) {
1924
+ await updateAutoBackupStatus(instanceId, {
1925
+ last_backup_ok: false,
1926
+ consecutive_failures: (liveConfig.consecutive_failures || 0) + 1,
1927
+ warnings: ["Auto-backup paused: disk space below 2GB"],
1928
+ });
1929
+ console.warn(`[auto-backup] ${instanceId}: paused, disk below 2GB`);
1930
+ // Still reschedule to check again later
1931
+ const fresh = await getAutoBackupConfig(instanceId);
1932
+ if (fresh?.enabled)
1933
+ scheduleAutoBackup(instanceId, fresh);
1934
+ return;
1935
+ }
1936
+ // Check if changed since last backup
1937
+ if (await hasChangedSince(instanceId, liveLastAt)) {
1938
+ // Enqueue to avoid parallel I/O contention on RPi SD card
1939
+ await enqueueJobAndWait(instanceId, "auto-backup", async (job) => {
1940
+ updateJobProgress(job.id, "Backing up...");
1941
+ const result = await backupInstance(instanceId, { type: "auto-backup" });
1942
+ updateJobProgress(job.id, "Cleaning old backups...");
1943
+ await cleanOldAutoBackups(instanceId, keepCount);
1944
+ return result;
1945
+ });
1946
+ await updateAutoBackupStatus(instanceId, {
1947
+ last_backup_at: new Date().toISOString(),
1948
+ last_backup_ok: true,
1949
+ consecutive_failures: 0,
1950
+ warnings: [],
1951
+ });
1952
+ console.log(`[auto-backup] ${instanceId}: completed`);
1953
+ }
1954
+ else {
1955
+ console.log(`[auto-backup] ${instanceId}: skipped (no changes)`);
1956
+ }
1957
+ }
1958
+ catch (e) {
1959
+ const failures = (liveConfig.consecutive_failures || 0) + 1;
1960
+ const warnings = failures >= 3
1961
+ ? [`Auto-backup failed ${failures} times: ${e.message}`]
1962
+ : [];
1963
+ await updateAutoBackupStatus(instanceId, {
1964
+ last_backup_ok: false,
1965
+ consecutive_failures: failures,
1966
+ warnings,
1967
+ });
1968
+ console.error(`[auto-backup] ${instanceId} failed (${failures}x):`, e.message);
1969
+ }
1970
+ // Reschedule (re-read config in case user changed it)
1971
+ const freshConfig = await getAutoBackupConfig(instanceId);
1972
+ if (freshConfig?.enabled)
1973
+ scheduleAutoBackup(instanceId, freshConfig);
1974
+ }, delay);
1975
+ timer.unref(); // Don't prevent process exit
1976
+ autoBackupTimers.set(instanceId, timer);
1977
+ }
1978
+ /** Cancel auto-backup for an instance */
1979
+ export function cancelAutoBackup(instanceId) {
1980
+ const t = autoBackupTimers.get(instanceId);
1981
+ if (t) {
1982
+ clearTimeout(t);
1983
+ autoBackupTimers.delete(instanceId);
1984
+ }
1985
+ }
1986
+ /** Initialize auto-backup for all instances (call on server startup) */
1987
+ export async function initAutoBackup() {
1988
+ try {
1989
+ const { listInstances } = await import("./instance-manager.js");
1990
+ const instances = listInstances();
1991
+ let jitterIndex = 0;
1992
+ for (const inst of instances) {
1993
+ const config = inst.auto_backup;
1994
+ if (config?.enabled) {
1995
+ // Stagger startup: if multiple instances are due at the same time,
1996
+ // add incremental jitter (2 min per instance) to avoid I/O contention
1997
+ if (!config.last_backup_at) {
1998
+ const jitterMs = jitterIndex * 2 * 60_000; // 2 min apart
1999
+ const jitteredConfig = { ...config, last_backup_at: new Date(Date.now() - (config.interval_hours * 3600_000) + jitterMs).toISOString() };
2000
+ scheduleAutoBackup(inst.id, jitteredConfig);
2001
+ }
2002
+ else {
2003
+ scheduleAutoBackup(inst.id, config);
2004
+ }
2005
+ jitterIndex++;
2006
+ console.log(`[auto-backup] ${inst.id}: scheduled (interval: ${config.interval_hours}h, keep: ${config.keep_count})`);
2007
+ }
2008
+ }
2009
+ }
2010
+ catch (e) {
2011
+ console.error("[auto-backup] Init failed:", e.message);
2012
+ }
2013
+ }
2014
+ //# sourceMappingURL=backup-manager.js.map