@dambrogia/openclaw-agents-backup 0.1.0
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/.eslintrc.json +24 -0
- package/.github/workflows/test.yml +40 -0
- package/BACKUP_GITIGNORE_TEMPLATE +51 -0
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/SKILL.md +355 -0
- package/dist/cli.js +191 -0
- package/dist/index.js +22 -0
- package/package.json +63 -0
- package/src/agentLister.ts +23 -0
- package/src/backup.ts +190 -0
- package/src/cli.ts +176 -0
- package/src/encryptionService.ts +126 -0
- package/src/index.ts +5 -0
- package/src/restore.ts +143 -0
- package/src/types.ts +71 -0
- package/src/utils.ts +170 -0
- package/tests/agentLister.test.ts +71 -0
- package/tests/backup.test.ts +92 -0
- package/tests/encryptionService.test.ts +278 -0
- package/tests/restore.test.ts +167 -0
- package/tests/utils.test.ts +51 -0
- package/tsconfig.json +25 -0
package/src/backup.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import { BackupConfig, BackupResult, BackupChange, AgentArchiveMetadata } from './types';
|
|
4
|
+
import {
|
|
5
|
+
pathExists,
|
|
6
|
+
rsyncDirectory,
|
|
7
|
+
writeJsonFile,
|
|
8
|
+
ensureDirectoryExists,
|
|
9
|
+
executeCommand,
|
|
10
|
+
getCurrentTimestamp,
|
|
11
|
+
findAllFiles
|
|
12
|
+
} from './utils';
|
|
13
|
+
import { listAgents, validateAgentBinding } from './agentLister';
|
|
14
|
+
import { encryptFile } from './encryptionService';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Perform backup of all agents to the configured backup repository
|
|
18
|
+
*/
|
|
19
|
+
export async function performBackup(workspacePath: string): Promise<BackupResult> {
|
|
20
|
+
try {
|
|
21
|
+
// Load backup configuration
|
|
22
|
+
const configPath = path.join(workspacePath, '.backupconfig.json');
|
|
23
|
+
if (!pathExists(configPath)) {
|
|
24
|
+
return {
|
|
25
|
+
success: false,
|
|
26
|
+
message: 'Backup config not found at .backupconfig.json',
|
|
27
|
+
agentsProcessed: 0,
|
|
28
|
+
changes: [],
|
|
29
|
+
error: 'Missing .backupconfig.json'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as BackupConfig;
|
|
34
|
+
const backupRepoPath = config.backupRepoPath;
|
|
35
|
+
|
|
36
|
+
if (!pathExists(backupRepoPath)) {
|
|
37
|
+
return {
|
|
38
|
+
success: false,
|
|
39
|
+
message: `Backup repository not found at ${backupRepoPath}`,
|
|
40
|
+
agentsProcessed: 0,
|
|
41
|
+
changes: [],
|
|
42
|
+
error: 'Backup repo not initialized'
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get encryption password from environment
|
|
47
|
+
const encryptionPassword = process.env.BACKUP_ENCRYPTION_PASSWORD;
|
|
48
|
+
if (!encryptionPassword) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
message: 'Backup encryption password not set',
|
|
52
|
+
agentsProcessed: 0,
|
|
53
|
+
changes: [],
|
|
54
|
+
error: 'Missing BACKUP_ENCRYPTION_PASSWORD environment variable'
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get list of agents
|
|
59
|
+
const agents = await listAgents();
|
|
60
|
+
|
|
61
|
+
// Validate agents
|
|
62
|
+
const validAgents = agents.filter((agent) => {
|
|
63
|
+
const valid = validateAgentBinding(agent);
|
|
64
|
+
if (!valid) {
|
|
65
|
+
console.warn(`Agent ${agent.id} failed validation, skipping`);
|
|
66
|
+
}
|
|
67
|
+
return valid;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const changes: BackupChange[] = [];
|
|
71
|
+
const archivesPath = path.join(backupRepoPath, 'archives');
|
|
72
|
+
ensureDirectoryExists(archivesPath);
|
|
73
|
+
|
|
74
|
+
// Back up each agent
|
|
75
|
+
for (const agent of validAgents) {
|
|
76
|
+
const agentArchivePath = path.join(archivesPath, agent.id);
|
|
77
|
+
ensureDirectoryExists(agentArchivePath);
|
|
78
|
+
|
|
79
|
+
const backupChange: BackupChange = {
|
|
80
|
+
agentId: agent.id,
|
|
81
|
+
workspaceChanged: false,
|
|
82
|
+
agentDirChanged: false
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Write agent metadata
|
|
87
|
+
const metadata: AgentArchiveMetadata = {
|
|
88
|
+
id: agent.id,
|
|
89
|
+
identityName: agent.identityName,
|
|
90
|
+
identityEmoji: agent.identityEmoji,
|
|
91
|
+
identitySource: agent.identitySource,
|
|
92
|
+
workspace: agent.workspace,
|
|
93
|
+
agentDir: agent.agentDir,
|
|
94
|
+
model: agent.model,
|
|
95
|
+
bindings: agent.bindings,
|
|
96
|
+
isDefault: agent.isDefault,
|
|
97
|
+
routes: agent.routes,
|
|
98
|
+
backedUpAt: getCurrentTimestamp()
|
|
99
|
+
};
|
|
100
|
+
writeJsonFile(path.join(agentArchivePath, 'agent.json'), metadata);
|
|
101
|
+
|
|
102
|
+
// Sync workspace
|
|
103
|
+
const workspaceDestination = path.join(agentArchivePath, 'workspace');
|
|
104
|
+
ensureDirectoryExists(workspaceDestination);
|
|
105
|
+
backupChange.workspaceChanged = rsyncDirectory(agent.workspace, workspaceDestination);
|
|
106
|
+
|
|
107
|
+
// Sync agent directory (includes both agent/ and sessions/ directories)
|
|
108
|
+
// Source: ${agentDir}/.. to capture agent/ and sessions/ together
|
|
109
|
+
const agentDirParent = path.join(agent.agentDir, '..');
|
|
110
|
+
const agentDirDestination = path.join(agentArchivePath, 'agentDir');
|
|
111
|
+
ensureDirectoryExists(agentDirDestination);
|
|
112
|
+
backupChange.agentDirChanged = rsyncDirectory(agentDirParent, agentDirDestination);
|
|
113
|
+
|
|
114
|
+
// Encrypt all files in agentDir backup
|
|
115
|
+
const allFiles = findAllFiles(agentDirDestination);
|
|
116
|
+
for (const file of allFiles) {
|
|
117
|
+
// Skip already encrypted files
|
|
118
|
+
if (file.endsWith('.enc')) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const encryptedPath = `${file}.enc`;
|
|
124
|
+
encryptFile(file, encryptedPath, encryptionPassword);
|
|
125
|
+
// Delete plaintext after successful encryption
|
|
126
|
+
fs.unlinkSync(file);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
backupChange.error = `Failed to encrypt ${file}: ${error}`;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
changes.push(backupChange);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
backupChange.error = String(error);
|
|
136
|
+
changes.push(backupChange);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Always commit since metadata (backedUpAt) is always updated
|
|
141
|
+
try {
|
|
142
|
+
await gitCommitBackup(backupRepoPath);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
message: 'Backup completed but git commit failed',
|
|
147
|
+
agentsProcessed: validAgents.length,
|
|
148
|
+
changes,
|
|
149
|
+
error: String(error)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
success: true,
|
|
155
|
+
message: `Backed up ${validAgents.length} agents. Changes: ${changes.filter((c) => c.workspaceChanged || c.agentDirChanged).length}`,
|
|
156
|
+
agentsProcessed: validAgents.length,
|
|
157
|
+
changes
|
|
158
|
+
};
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
message: 'Backup failed',
|
|
163
|
+
agentsProcessed: 0,
|
|
164
|
+
changes: [],
|
|
165
|
+
error: String(error)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Commit backup changes to git repository
|
|
172
|
+
*/
|
|
173
|
+
async function gitCommitBackup(repoPath: string): Promise<void> {
|
|
174
|
+
const currentDir = process.cwd();
|
|
175
|
+
try {
|
|
176
|
+
process.chdir(repoPath);
|
|
177
|
+
|
|
178
|
+
// Stage all changes
|
|
179
|
+
executeCommand('git add -A');
|
|
180
|
+
|
|
181
|
+
// Create commit message
|
|
182
|
+
const timestamp = getCurrentTimestamp();
|
|
183
|
+
const commitMessage = `Backup: ${timestamp}`;
|
|
184
|
+
|
|
185
|
+
// Commit changes
|
|
186
|
+
executeCommand(`git commit -m "${commitMessage}"`);
|
|
187
|
+
} finally {
|
|
188
|
+
process.chdir(currentDir);
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { performBackup, performRestore } from './index';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CLI entry point for backup/restore operations
|
|
9
|
+
*/
|
|
10
|
+
async function main(): Promise<void> {
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const command = args[0];
|
|
13
|
+
|
|
14
|
+
const workspace = process.env.OPENCLAW_WORKSPACE || '/root/.openclaw/workspace';
|
|
15
|
+
const configPath = path.join(workspace, '.backupconfig.json');
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(configPath)) {
|
|
18
|
+
console.error('❌ Error: .backupconfig.json not found at ' + configPath);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
23
|
+
const backupRepoPath = config.backupRepoPath;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
switch (command) {
|
|
27
|
+
case 'backup':
|
|
28
|
+
await handleBackup(workspace);
|
|
29
|
+
break;
|
|
30
|
+
|
|
31
|
+
case 'restore':
|
|
32
|
+
await handleRestore(backupRepoPath, workspace, args);
|
|
33
|
+
break;
|
|
34
|
+
|
|
35
|
+
case 'history':
|
|
36
|
+
await handleHistory(backupRepoPath);
|
|
37
|
+
break;
|
|
38
|
+
|
|
39
|
+
case '--help':
|
|
40
|
+
case '-h':
|
|
41
|
+
case undefined:
|
|
42
|
+
showHelp();
|
|
43
|
+
break;
|
|
44
|
+
|
|
45
|
+
default:
|
|
46
|
+
console.error('❌ Unknown command: ' + command);
|
|
47
|
+
showHelp();
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`❌ Error: ${error}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handleBackup(workspace: string): Promise<void> {
|
|
57
|
+
console.log('🔄 Starting backup...');
|
|
58
|
+
const result = await performBackup(workspace);
|
|
59
|
+
|
|
60
|
+
if (result.success) {
|
|
61
|
+
console.log('✅ Backup complete');
|
|
62
|
+
console.log(` Agents processed: ${result.agentsProcessed}`);
|
|
63
|
+
console.log(
|
|
64
|
+
` Changes: ${result.changes.filter((c) => c.workspaceChanged || c.agentDirChanged).length}`
|
|
65
|
+
);
|
|
66
|
+
result.changes.forEach((change) => {
|
|
67
|
+
if (change.workspaceChanged || change.agentDirChanged) {
|
|
68
|
+
console.log(` - ${change.agentId}: workspace=${change.workspaceChanged}, agentDir=${change.agentDirChanged}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
console.error(`❌ Backup failed: ${result.message}`);
|
|
73
|
+
if (result.error) {
|
|
74
|
+
console.error(` ${result.error}`);
|
|
75
|
+
}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handleRestore(
|
|
81
|
+
backupRepoPath: string,
|
|
82
|
+
workspace: string,
|
|
83
|
+
args: string[]
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
let targetSha: string | null = null;
|
|
86
|
+
let confirmAuthOverwrite = false;
|
|
87
|
+
|
|
88
|
+
// Check for --sha argument
|
|
89
|
+
const shaIndex = args.indexOf('--sha');
|
|
90
|
+
if (shaIndex !== -1 && shaIndex + 1 < args.length) {
|
|
91
|
+
targetSha = args[shaIndex + 1];
|
|
92
|
+
console.log(`🔄 Restoring to commit: ${targetSha}`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log('🔄 Restoring to latest backup...');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check for --confirm-auth-overwrite flag
|
|
98
|
+
if (args.includes('--confirm-auth-overwrite')) {
|
|
99
|
+
confirmAuthOverwrite = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await performRestore(backupRepoPath, targetSha, workspace, confirmAuthOverwrite);
|
|
103
|
+
|
|
104
|
+
if (result.success) {
|
|
105
|
+
console.log('✅ Restore complete');
|
|
106
|
+
console.log(` Agents restored: ${result.agentsRestored}`);
|
|
107
|
+
} else {
|
|
108
|
+
if (result.authOverwriteWarning) {
|
|
109
|
+
console.warn(`⚠️ ${result.message}`);
|
|
110
|
+
console.warn(`\n To restore including sensitive credentials, run:\n backup-agents restore ${targetSha ? `--sha ${targetSha}` : ''} --confirm-auth-overwrite`);
|
|
111
|
+
} else {
|
|
112
|
+
console.error(`❌ Restore failed: ${result.message}`);
|
|
113
|
+
if (result.error) {
|
|
114
|
+
console.error(` ${result.error}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleHistory(backupRepoPath: string): Promise<void> {
|
|
122
|
+
const { executeCommand } = require('./utils');
|
|
123
|
+
|
|
124
|
+
console.log('📜 Backup history:\n');
|
|
125
|
+
try {
|
|
126
|
+
const log = executeCommand(`cd ${backupRepoPath} && git log --oneline -20`);
|
|
127
|
+
console.log(log);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`❌ Failed to fetch history: ${error}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function showHelp(): void {
|
|
135
|
+
console.log(`
|
|
136
|
+
@dambrogia/openclaw-agents-backup CLI
|
|
137
|
+
|
|
138
|
+
Usage:
|
|
139
|
+
backup-agents [command] [options]
|
|
140
|
+
|
|
141
|
+
Commands:
|
|
142
|
+
backup Backup all agents now
|
|
143
|
+
restore [options] Restore agents
|
|
144
|
+
history Show recent backup history
|
|
145
|
+
--help, -h Show this help message
|
|
146
|
+
|
|
147
|
+
Restore Options:
|
|
148
|
+
--sha SHA Restore to specific commit
|
|
149
|
+
--confirm-auth-overwrite Allow overwriting auth-profiles.json (API tokens)
|
|
150
|
+
|
|
151
|
+
Examples:
|
|
152
|
+
# Backup now
|
|
153
|
+
backup-agents backup
|
|
154
|
+
|
|
155
|
+
# Restore latest
|
|
156
|
+
backup-agents restore
|
|
157
|
+
|
|
158
|
+
# Restore to specific point in time
|
|
159
|
+
backup-agents restore --sha abc123def456
|
|
160
|
+
|
|
161
|
+
# Restore including sensitive credentials
|
|
162
|
+
backup-agents restore --confirm-auth-overwrite
|
|
163
|
+
|
|
164
|
+
# View backup history
|
|
165
|
+
backup-agents history
|
|
166
|
+
|
|
167
|
+
Environment:
|
|
168
|
+
OPENCLAW_WORKSPACE Path to OpenClaw workspace (default: /root/.openclaw/workspace)
|
|
169
|
+
BACKUP_ENCRYPTION_PASSWORD Password for encrypting/decrypting files (required)
|
|
170
|
+
`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
main().catch((error) => {
|
|
174
|
+
console.error(`❌ Unexpected error: ${error}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
|
|
4
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
5
|
+
const SALT_LENGTH = 16;
|
|
6
|
+
const IV_LENGTH = 16;
|
|
7
|
+
const AUTH_TAG_LENGTH = 16;
|
|
8
|
+
const PBKDF2_ITERATIONS = 100000;
|
|
9
|
+
const PBKDF2_DIGEST = 'sha256';
|
|
10
|
+
|
|
11
|
+
export interface EncryptionHeader {
|
|
12
|
+
salt: Buffer;
|
|
13
|
+
iv: Buffer;
|
|
14
|
+
authTag: Buffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encrypt a file with AES-256-GCM using PBKDF2 key derivation
|
|
19
|
+
* @param inputPath Path to input file
|
|
20
|
+
* @param outputPath Path to output file (.jsonl.enc)
|
|
21
|
+
* @param password Encryption password
|
|
22
|
+
*/
|
|
23
|
+
export function encryptFile(inputPath: string, outputPath: string, password: string): void {
|
|
24
|
+
// Generate random salt and IV
|
|
25
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
26
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
27
|
+
|
|
28
|
+
// Derive key from password using PBKDF2
|
|
29
|
+
const key = crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, 32, PBKDF2_DIGEST);
|
|
30
|
+
|
|
31
|
+
// Read plaintext
|
|
32
|
+
const plaintext = fs.readFileSync(inputPath);
|
|
33
|
+
|
|
34
|
+
// Encrypt
|
|
35
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
36
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
37
|
+
const authTag = cipher.getAuthTag();
|
|
38
|
+
|
|
39
|
+
// Write: [salt][iv][authTag][ciphertext]
|
|
40
|
+
const output = Buffer.concat([salt, iv, authTag, encrypted]);
|
|
41
|
+
fs.writeFileSync(outputPath, output);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decrypt a file encrypted with encryptFile()
|
|
46
|
+
* @param inputPath Path to input file (.jsonl.enc)
|
|
47
|
+
* @param outputPath Path to output file
|
|
48
|
+
* @param password Encryption password
|
|
49
|
+
*/
|
|
50
|
+
export function decryptFile(inputPath: string, outputPath: string, password: string): void {
|
|
51
|
+
// Read encrypted file
|
|
52
|
+
const encrypted = fs.readFileSync(inputPath);
|
|
53
|
+
|
|
54
|
+
// Parse header: [salt][iv][authTag][ciphertext]
|
|
55
|
+
if (encrypted.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH) {
|
|
56
|
+
throw new Error('Invalid encrypted file: too short');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let offset = 0;
|
|
60
|
+
const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
|
|
61
|
+
const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
|
|
62
|
+
const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
|
|
63
|
+
const ciphertext = encrypted.subarray(offset);
|
|
64
|
+
|
|
65
|
+
// Derive key from password using same parameters
|
|
66
|
+
const key = crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, 32, PBKDF2_DIGEST);
|
|
67
|
+
|
|
68
|
+
// Decrypt
|
|
69
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
70
|
+
decipher.setAuthTag(authTag);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
74
|
+
fs.writeFileSync(outputPath, plaintext);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error(`Decryption failed: invalid password or corrupted file - ${error}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Encrypt a buffer with AES-256-GCM
|
|
82
|
+
* @param data Buffer to encrypt
|
|
83
|
+
* @param password Encryption password
|
|
84
|
+
* @returns Encrypted buffer with header
|
|
85
|
+
*/
|
|
86
|
+
export function encryptBuffer(data: Buffer, password: string): Buffer {
|
|
87
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
88
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
89
|
+
|
|
90
|
+
const key = crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, 32, PBKDF2_DIGEST);
|
|
91
|
+
|
|
92
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
93
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
94
|
+
const authTag = cipher.getAuthTag();
|
|
95
|
+
|
|
96
|
+
return Buffer.concat([salt, iv, authTag, encrypted]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decrypt a buffer encrypted with encryptBuffer()
|
|
101
|
+
* @param encrypted Encrypted buffer with header
|
|
102
|
+
* @param password Encryption password
|
|
103
|
+
* @returns Decrypted buffer
|
|
104
|
+
*/
|
|
105
|
+
export function decryptBuffer(encrypted: Buffer, password: string): Buffer {
|
|
106
|
+
if (encrypted.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH) {
|
|
107
|
+
throw new Error('Invalid encrypted buffer: too short');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let offset = 0;
|
|
111
|
+
const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
|
|
112
|
+
const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
|
|
113
|
+
const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
|
|
114
|
+
const ciphertext = encrypted.subarray(offset);
|
|
115
|
+
|
|
116
|
+
const key = crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, 32, PBKDF2_DIGEST);
|
|
117
|
+
|
|
118
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
119
|
+
decipher.setAuthTag(authTag);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(`Decryption failed: invalid password or corrupted data - ${error}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/index.ts
ADDED
package/src/restore.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import { RestoreResult, AgentArchiveMetadata } from './types';
|
|
4
|
+
import { pathExists, rsyncDirectory, readJsonFile, ensureDirectoryExists, findEncryptedFiles } from './utils';
|
|
5
|
+
import { decryptFile } from './encryptionService';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Restore agents from backup to specified git SHA or latest
|
|
9
|
+
* @param backupRepoPath Path to backup repository
|
|
10
|
+
* @param targetSha Git SHA to restore from (if null, uses current state)
|
|
11
|
+
* @param workspacePath Base path where agents will be restored
|
|
12
|
+
* @param confirmAuthOverwrite If true, allows overwriting auth-profiles.json without warning
|
|
13
|
+
*/
|
|
14
|
+
export async function performRestore(
|
|
15
|
+
backupRepoPath: string,
|
|
16
|
+
targetSha: string | null,
|
|
17
|
+
_workspacePath: string,
|
|
18
|
+
confirmAuthOverwrite: boolean = false
|
|
19
|
+
): Promise<RestoreResult> {
|
|
20
|
+
try {
|
|
21
|
+
if (!pathExists(backupRepoPath)) {
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
message: `Backup repository not found at ${backupRepoPath}`,
|
|
25
|
+
agentsRestored: 0,
|
|
26
|
+
error: 'Backup repo not found'
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const archivesPath = path.join(backupRepoPath, 'archives');
|
|
31
|
+
if (!pathExists(archivesPath)) {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
message: 'Archives directory not found in backup repository',
|
|
35
|
+
agentsRestored: 0,
|
|
36
|
+
error: 'No archives found'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Get encryption password from environment
|
|
41
|
+
const encryptionPassword = process.env.BACKUP_ENCRYPTION_PASSWORD;
|
|
42
|
+
if (!encryptionPassword) {
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
message: 'Backup encryption password not set',
|
|
46
|
+
agentsRestored: 0,
|
|
47
|
+
error: 'Missing BACKUP_ENCRYPTION_PASSWORD environment variable'
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If target SHA specified, we would ideally check out that commit
|
|
52
|
+
// For now, we work with current state (could be enhanced)
|
|
53
|
+
if (targetSha) {
|
|
54
|
+
console.warn(`Note: targetSha specified (${targetSha}) but not implemented in this version`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// List all agent directories
|
|
58
|
+
const agentDirs = fs
|
|
59
|
+
.readdirSync(archivesPath, { withFileTypes: true })
|
|
60
|
+
.filter((dirent) => dirent.isDirectory())
|
|
61
|
+
.map((dirent) => dirent.name);
|
|
62
|
+
|
|
63
|
+
let agentsRestored = 0;
|
|
64
|
+
const errors: string[] = [];
|
|
65
|
+
|
|
66
|
+
// Restore each agent
|
|
67
|
+
for (const agentId of agentDirs) {
|
|
68
|
+
const agentArchivePath = path.join(archivesPath, agentId);
|
|
69
|
+
const metadataPath = path.join(agentArchivePath, 'agent.json');
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Read metadata
|
|
73
|
+
const metadata = readJsonFile<AgentArchiveMetadata>(metadataPath);
|
|
74
|
+
|
|
75
|
+
// Restore workspace
|
|
76
|
+
const workspaceSource = path.join(agentArchivePath, 'workspace');
|
|
77
|
+
if (pathExists(workspaceSource)) {
|
|
78
|
+
ensureDirectoryExists(metadata.workspace);
|
|
79
|
+
rsyncDirectory(workspaceSource, metadata.workspace);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Restore agent directory
|
|
83
|
+
const agentDirSource = path.join(agentArchivePath, 'agentDir');
|
|
84
|
+
if (pathExists(agentDirSource)) {
|
|
85
|
+
// Decrypt all .enc files before restoring
|
|
86
|
+
const encryptedFiles = findEncryptedFiles(agentDirSource);
|
|
87
|
+
for (const encFile of encryptedFiles) {
|
|
88
|
+
try {
|
|
89
|
+
const plainPath = encFile.substring(0, encFile.length - 4); // Remove .enc suffix
|
|
90
|
+
decryptFile(encFile, plainPath, encryptionPassword);
|
|
91
|
+
// Delete encrypted file after successful decryption
|
|
92
|
+
fs.unlinkSync(encFile);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`Failed to decrypt ${encFile}: ${error}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if auth-profiles.json exists in the backup
|
|
99
|
+
const backupAuthPath = path.join(agentDirSource, 'agent', 'auth-profiles.json');
|
|
100
|
+
if (pathExists(backupAuthPath)) {
|
|
101
|
+
if (!confirmAuthOverwrite) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
message: 'Backup contains sensitive auth-profiles.json (API tokens). Use --confirm-auth-overwrite to proceed.',
|
|
105
|
+
agentsRestored: 0,
|
|
106
|
+
authOverwriteWarning: true
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ensureDirectoryExists(metadata.agentDir);
|
|
112
|
+
rsyncDirectory(agentDirSource, metadata.agentDir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
agentsRestored++;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
errors.push(`Agent ${agentId}: ${error}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (errors.length > 0) {
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
message: `Restored ${agentsRestored} agents with errors`,
|
|
125
|
+
agentsRestored,
|
|
126
|
+
error: errors.join('\n')
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
message: `Successfully restored ${agentsRestored} agents`,
|
|
133
|
+
agentsRestored
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
message: 'Restore operation failed',
|
|
139
|
+
agentsRestored: 0,
|
|
140
|
+
error: String(error)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|