jishushell 0.4.2 → 0.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile.openclaw-slim +58 -0
- package/INSTALL-NOTICE +45 -0
- package/dist/auth.js +3 -3
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +3 -0
- package/dist/cli/app.js +156 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/{doctor.d.ts → cli/doctor.d.ts} +6 -1
- package/dist/{doctor.js → cli/doctor.js} +389 -27
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/helpers.d.ts +4 -0
- package/dist/cli/helpers.js +32 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/job.d.ts +3 -0
- package/dist/cli/job.js +260 -0
- package/dist/cli/job.js.map +1 -0
- package/dist/cli/llm.d.ts +24 -0
- package/dist/cli/llm.js +593 -0
- package/dist/cli/llm.js.map +1 -0
- package/dist/cli/openclaw.d.ts +12 -0
- package/dist/cli/openclaw.js +156 -0
- package/dist/cli/openclaw.js.map +1 -0
- package/dist/cli/panel.d.ts +25 -0
- package/dist/cli/panel.js +734 -0
- package/dist/cli/panel.js.map +1 -0
- package/dist/cli.js +476 -219
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +22 -4
- package/dist/config.js +96 -55
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +13 -41
- package/dist/control.js +12 -1355
- package/dist/control.js.map +1 -1
- package/dist/install.d.ts +1 -1
- package/dist/install.js +15 -29
- package/dist/install.js.map +1 -1
- package/dist/routes/apps.d.ts +3 -0
- package/dist/routes/apps.js +99 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/backup.d.ts +2 -0
- package/dist/routes/backup.js +370 -0
- package/dist/routes/backup.js.map +1 -0
- package/dist/routes/instances.d.ts +1 -0
- package/dist/routes/instances.js +61 -15
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.d.ts +15 -0
- package/dist/routes/llm.js +246 -0
- package/dist/routes/llm.js.map +1 -0
- package/dist/routes/setup.js +32 -7
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +31 -6
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +69 -5
- package/dist/server.js.map +1 -1
- package/dist/services/app-compiler.d.ts +15 -0
- package/dist/services/app-compiler.js +169 -0
- package/dist/services/app-compiler.js.map +1 -0
- package/dist/services/app-manager.d.ts +17 -0
- package/dist/services/app-manager.js +168 -0
- package/dist/services/app-manager.js.map +1 -0
- package/dist/services/backup-manager.d.ts +253 -0
- package/dist/services/backup-manager.js +2014 -0
- package/dist/services/backup-manager.js.map +1 -0
- package/dist/services/backup-verify.d.ts +26 -0
- package/dist/services/backup-verify.js +240 -0
- package/dist/services/backup-verify.js.map +1 -0
- package/dist/services/instance-manager.d.ts +73 -5
- package/dist/services/instance-manager.js +446 -74
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/job-manager.d.ts +22 -0
- package/dist/services/job-manager.js +102 -0
- package/dist/services/job-manager.js.map +1 -0
- package/dist/services/llm-proxy/adapters.js +5 -1
- package/dist/services/llm-proxy/adapters.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +30 -0
- package/dist/services/llm-proxy/index.js +71 -1
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/llm-proxy/ssrf.js +1 -1
- package/dist/services/llm-proxy/ssrf.js.map +1 -1
- package/dist/services/nomad-manager.js +263 -159
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +40 -0
- package/dist/services/panel-manager.js +346 -0
- package/dist/services/panel-manager.js.map +1 -0
- package/dist/services/process-manager.js +24 -10
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/setup-manager.d.ts +4 -2
- package/dist/services/setup-manager.js +578 -154
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/telemetry/activation.js +10 -7
- package/dist/services/telemetry/activation.js.map +1 -1
- package/dist/services/telemetry/client.js +7 -18
- package/dist/services/telemetry/client.js.map +1 -1
- package/dist/services/telemetry/heartbeat.js +12 -6
- package/dist/services/telemetry/heartbeat.js.map +1 -1
- package/dist/services/update-manager.d.ts +47 -0
- package/dist/services/update-manager.js +305 -0
- package/dist/services/update-manager.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/utils/fs.d.ts +85 -0
- package/dist/utils/fs.js +111 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/safe-json.d.ts +2 -0
- package/dist/utils/safe-json.js +22 -16
- package/dist/utils/safe-json.js.map +1 -1
- package/install/jishu-install.sh +582 -138
- package/install/jishu-uninstall.sh +276 -391
- package/install/post-install.sh +85 -3
- package/openclaw-entry.sh +15 -0
- package/package.json +12 -5
- package/public/assets/Dashboard-CQsp1Mr9.js +1 -0
- package/public/assets/InitPassword-BEC8SE4A.js +1 -0
- package/public/assets/InstanceDetail-B5wTgNEg.js +17 -0
- package/public/assets/{Login-RkjzTNWg.js → Login-D1Bt-Lyk.js} +1 -1
- package/public/assets/NewInstance-GQzm3K9D.js +1 -0
- package/public/assets/Settings-ByjGlqhP.js +1 -0
- package/public/assets/Setup-cMF21Y-8.js +1 -0
- package/public/assets/index-B6qQP4mH.css +1 -0
- package/public/assets/index-BuTQtuNy.js +16 -0
- package/public/assets/logo-black-theme-DywLAtFy.png +0 -0
- package/public/assets/logo-white-theme-DXffFAWw.png +0 -0
- package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
- package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
- package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
- package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
- package/public/index.html +4 -4
- package/dist/doctor.js.map +0 -1
- package/public/assets/Dashboard-CAOQDYDR.js +0 -1
- package/public/assets/InitPassword-CkehIkJG.js +0 -1
- package/public/assets/InstanceDetail-CzW2S95J.js +0 -14
- package/public/assets/NewInstance-DdbErdjA.js +0 -1
- package/public/assets/Settings-BUD7zwv9.js +0 -1
- package/public/assets/Setup-RRTIERGG.js +0 -1
- package/public/assets/index-77Ug7feY.css +0 -1
- package/public/assets/index-DfRnVUQR.js +0 -16
- 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
|