@flame0510/project-aether 1.2.0 → 1.4.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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. package/app/api/gateway/route.ts +0 -191
@@ -22,6 +22,7 @@
22
22
  * - concepts/multi-agent.md (bindings)
23
23
  */
24
24
  import { execSync } from 'child_process';
25
+ import { dockerExec, dockerExecShellNoFail } from '@/lib/docker-exec';
25
26
 
26
27
  // ── Types ────────────────────────────────────
27
28
 
@@ -134,19 +135,22 @@ export function getChannels(agentId: string): AgentChannels {
134
135
 
135
136
  // ── Telegram ─────────────────────────────────
136
137
 
137
- export function setTelegramConfig(
138
+ export async function setTelegramConfig(
138
139
  agentId: string,
139
140
  params: {
140
141
  botToken: string;
141
142
  dmPolicy?: string;
142
143
  allowFrom?: string[];
143
144
  },
144
- ): { success: boolean; error?: string } {
145
+ ): Promise<{ success: boolean; error?: string }> {
145
146
  const container = findContainerById(agentId);
146
147
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
147
148
 
148
149
  try {
149
- const config = readConfigJson(container) ?? {};
150
+ // A failed read is not an empty config: writing `{}` back would replace the agent's
151
+ // whole openclaw.json with the channel block (the historical defect this route had).
152
+ const config = readConfigJson(container);
153
+ if (!config) return { success: false, error: 'Could not read the agent configuration' };
150
154
 
151
155
  // Ensure channels.telegram structure
152
156
  if (!config.channels) config.channels = {};
@@ -161,10 +165,7 @@ export function setTelegramConfig(
161
165
  // If token changed, wipe old pairing data to avoid orphaned allowFrom entries
162
166
  const oldToken = existing.botToken as string | undefined;
163
167
  if (oldToken && oldToken !== params.botToken && (existing.dmPolicy as string) === 'pairing') {
164
- containerExecNoFail(
165
- container,
166
- `rm -f ${pairingFiles('telegram')} 2>/dev/null`,
167
- );
168
+ await clearChannelAllowlist(container, 'telegram');
168
169
  }
169
170
 
170
171
  // Merge channel config: preserve ALL existing keys (provider, etc.)
@@ -190,7 +191,7 @@ export function setTelegramConfig(
190
191
  }
191
192
  }
192
193
 
193
- export function removeTelegram(agentId: string): { success: boolean; error?: string } {
194
+ export async function removeTelegram(agentId: string): Promise<{ success: boolean; error?: string }> {
194
195
  const container = findContainerById(agentId);
195
196
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
196
197
 
@@ -210,11 +211,8 @@ export function removeTelegram(agentId: string): { success: boolean; error?: str
210
211
  }
211
212
  }
212
213
 
213
- // Clean up pairing files — they belong to the disconnected bot
214
- containerExecNoFail(
215
- container,
216
- `rm -f ${pairingFiles('telegram')} 2>/dev/null`,
217
- );
214
+ // The approved senders belong to the disconnected bot: clear the pairing store.
215
+ await clearChannelAllowlist(container, 'telegram');
218
216
 
219
217
  removeBindings(config, agentId, 'telegram');
220
218
  writeConfigJson(container, config);
@@ -232,7 +230,7 @@ export function removeTelegram(agentId: string): { success: boolean; error?: str
232
230
  * The agentId in the binding MUST be "main" (OpenClaw's default agent)
233
231
  * so that sessions are created as `agent:main:main`.
234
232
  * Using the container name as agentId would create sessions like
235
- * `agent:NOMEAGENTE:main` which breaks model bindings that target
233
+ * `agent:<AGENT_NAME>:main` which breaks model bindings that target
236
234
  * `agent:main:*`.
237
235
  *
238
236
  * Per multi-agent.md: bindings route (channel, accountId) to agentId.
@@ -291,22 +289,125 @@ export interface PairingState {
291
289
  approved: ApprovedPairing[];
292
290
  }
293
291
 
294
- function pairingAllowFile(channel: string): string {
295
- return `/root/.openclaw/credentials/${channel}-default-allowFrom.json`;
292
+ /**
293
+ * Script run inside the container to read or edit OpenClaw's channel pairing store.
294
+ *
295
+ * On 2026.9.3 the approved senders live in `~/.openclaw/state/openclaw.sqlite`
296
+ * (`channel_pairing_allow_entries`), and no CLI, RPC or documented export reads or
297
+ * writes them: `openclaw pairing` covers pending requests only, `channels.pairing.*`
298
+ * has no remove, and the writers sit in `dist/pairing-store-<hash>.mjs` with minified
299
+ * export names. The function *names* are preserved, though, so the module is found by
300
+ * glob and its functions by `name` — no hash or minified symbol is hardcoded. The
301
+ * write goes through the same state transaction the CLI uses.
302
+ *
303
+ * `op`, `channel` and `entry` arrive as argv, never interpolated into the source.
304
+ * The sentinel lines are machine-readable: `docker exec` exits 0 even when killed by
305
+ * the timeout, so an empty result cannot be read as success (docs/ARCHITECTURE.md §3.1).
306
+ */
307
+ const PAIRING_STORE_SCRIPT = `
308
+ (async () => {
309
+ const fs = require('fs');
310
+ const path = require('path');
311
+ const { pathToFileURL } = require('url');
312
+ const [op, channel, entry] = process.argv.slice(1);
313
+ const dist = '/usr/local/lib/node_modules/openclaw/dist';
314
+ let remove, readSync;
315
+ try {
316
+ for (const f of fs.readdirSync(dist).filter((x) => /^pairing-store-.*\\.mjs$/.test(x))) {
317
+ const mod = await import(pathToFileURL(path.join(dist, f)).href);
318
+ for (const v of Object.values(mod)) {
319
+ if (typeof v !== 'function') continue;
320
+ if (v.name === 'removeChannelAllowFromStoreEntry') remove = v;
321
+ if (v.name === 'readChannelAllowFromStoreSync') readSync = v;
322
+ }
323
+ }
324
+ } catch (e) { console.log('pair:error ' + e.message); return; }
325
+ if (!remove || !readSync) { console.log('pair:unsupported'); return; }
326
+ // Entries are sender ids as strings today; anything else has a field to read. An
327
+ // unexpected shape is an error, never String(entry) — that would print
328
+ // "[object Object]" and then remove nothing while reporting success.
329
+ const normalize = (list) => (list || []).map((e) => {
330
+ if (typeof e === 'string') return e;
331
+ if (e && typeof e === 'object') { const v = e.id ?? e.senderId ?? e.entry; return typeof v === 'string' ? v : null; }
332
+ return null;
333
+ });
334
+ try {
335
+ if (op === 'read') {
336
+ const entries = normalize(readSync(channel));
337
+ if (entries.some((e) => e === null)) { console.log('pair:error unexpected allowlist entry shape'); return; }
338
+ console.log('pair:list ' + JSON.stringify(entries));
339
+ } else if (op === 'remove') {
340
+ const r = await remove({ channel, entry });
341
+ console.log(r && r.changed ? 'pair:ok' : 'pair:absent');
342
+ } else if (op === 'clear') {
343
+ const entries = normalize(readSync(channel));
344
+ if (entries.some((e) => e === null)) { console.log('pair:error unexpected allowlist entry shape'); return; }
345
+ for (const e of entries) await remove({ channel, entry: e });
346
+ console.log('pair:ok');
347
+ } else {
348
+ console.log('pair:error unknown op');
349
+ }
350
+ } catch (e) { console.log('pair:error ' + e.message); }
351
+ })();
352
+ `;
353
+
354
+ interface StoreOpResult {
355
+ status: 'ok' | 'absent' | 'unsupported' | 'error';
356
+ entries?: string[];
357
+ error?: string;
358
+ }
359
+
360
+ /** Channel ids reach shell commands and the store API; keep them to OpenClaw's shape. */
361
+ function isSafeChannel(channel: string): boolean {
362
+ return /^[a-z0-9_-]+$/.test(channel);
363
+ }
364
+
365
+ /** Run one pairing-store operation inside the container; see PAIRING_STORE_SCRIPT. */
366
+ async function runPairingStore(container: string, op: 'read' | 'remove' | 'clear', channel: string, entry?: string): Promise<StoreOpResult> {
367
+ const argv = ['node', '-e', PAIRING_STORE_SCRIPT, op, channel];
368
+ if (entry !== undefined) argv.push(entry);
369
+ let out: string;
370
+ try {
371
+ // argv reaches docker as an array — the script, the op and the sender id are
372
+ // never shell syntax. Async: the script loads OpenClaw's own modules inside the
373
+ // container and takes a second, which must not freeze Rev4a's event loop while
374
+ // the panel polls it.
375
+ out = (await dockerExec(container, argv, { timeoutMs: 20_000 })).trim();
376
+ } catch (e) {
377
+ return { status: 'error', error: (e as Error).message };
378
+ }
379
+ const listLine = out.split('\n').find((l) => l.startsWith('pair:list '));
380
+ if (listLine) {
381
+ try {
382
+ return { status: 'ok', entries: JSON.parse(listLine.slice('pair:list '.length)) as string[] };
383
+ } catch {
384
+ return { status: 'error', error: 'Could not read the approved senders list' };
385
+ }
386
+ }
387
+ if (out.includes('pair:ok')) return { status: 'ok' };
388
+ if (out.includes('pair:absent')) return { status: 'absent' };
389
+ if (out.includes('pair:unsupported')) return { status: 'unsupported' };
390
+ const errLine = out.split('\n').find((l) => l.startsWith('pair:error '));
391
+ return { status: 'error', error: errLine ? errLine.slice('pair:error '.length) : (out || 'Pairing store command produced no output') };
296
392
  }
297
393
 
298
- function pairingFiles(channel: string): string {
299
- return `/root/.openclaw/credentials/${channel}-pairing.json /root/.openclaw/credentials/${channel}-default-allowFrom.json`;
394
+ /** Drop every approved sender of a channel — a different bot must not inherit them. */
395
+ async function clearChannelAllowlist(container: string, channel: string): Promise<void> {
396
+ if (!isSafeChannel(channel)) return;
397
+ const result = await runPairingStore(container, 'clear', channel);
398
+ if (result.status === 'error') {
399
+ console.warn(`[channels] could not clear the ${channel} allowlist: ${result.error}`);
400
+ }
300
401
  }
301
402
 
302
403
  /** Get pending + approved pairings for a channel */
303
- export function getPairings(agentId: string, channel: string = 'telegram'): PairingState {
404
+ export async function getPairings(agentId: string, channel: string = 'telegram'): Promise<PairingState> {
304
405
  const container = findContainerById(agentId);
305
406
  const fallback: PairingState = { pending: [], approved: [] };
306
- if (!container) return fallback;
407
+ if (!container || !isSafeChannel(channel)) return fallback;
307
408
 
308
409
  // Pending
309
- const pendingRaw = containerExecNoFail(container, `openclaw pairing list --channel ${channel} --json 2>/dev/null`);
410
+ const pendingRaw = await dockerExecShellNoFail(container, `openclaw pairing list --channel ${channel} --json 2>/dev/null`);
310
411
  const pending: PendingPairing[] = [];
311
412
  if (pendingRaw) {
312
413
  try {
@@ -320,33 +421,25 @@ export function getPairings(agentId: string, channel: string = 'telegram'): Pair
320
421
  } catch { /* ignore */ }
321
422
  }
322
423
 
323
- // Approved: read from OpenClaw credentials file
324
- const allowRaw = containerExecNoFail(container, `cat ${pairingAllowFile(channel)} 2>/dev/null`);
424
+ // Approved: OpenClaw's pairing store (2026.9.x keeps it in SQLite; the legacy
425
+ // credentials files it replaced are gone, so reading those returned nothing).
325
426
  const approved: ApprovedPairing[] = [];
326
- if (allowRaw) {
327
- try {
328
- const parsed = JSON.parse(allowRaw);
329
- const entries = parsed?.allowFrom ?? [];
330
- for (const entry of entries) {
331
- if (typeof entry === 'string') {
332
- approved.push({ senderId: entry });
333
- } else if (entry?.senderId || entry?.id) {
334
- approved.push({ senderId: entry.senderId ?? entry.id });
335
- }
336
- }
337
- } catch { /* ignore */ }
427
+ const store = await runPairingStore(container, 'read', channel);
428
+ if (store.status === 'ok') {
429
+ for (const entry of store.entries ?? []) approved.push({ senderId: String(entry) });
338
430
  }
339
431
 
340
432
  return { pending, approved };
341
433
  }
342
434
 
343
435
  /** Approve a pending pairing code */
344
- export function approvePairing(agentId: string, code: string, channel: string = 'telegram'): { success: boolean; error?: string } {
436
+ export async function approvePairing(agentId: string, code: string, channel: string = 'telegram'): Promise<{ success: boolean; error?: string }> {
345
437
  const container = findContainerById(agentId);
346
438
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
439
+ if (!isSafeChannel(channel)) return { success: false, error: 'Invalid channel id' };
347
440
 
348
441
  try {
349
- const out = containerExecNoFail(container, `openclaw pairing approve ${channel} ${escapeShellArg(code)} 2>&1`);
442
+ const out = await dockerExecShellNoFail(container, `openclaw pairing approve ${channel} ${escapeShellArg(code)} 2>&1`);
350
443
  if (!out) {
351
444
  return { success: false, error: 'Command produced no output — pairing command may not be available' };
352
445
  }
@@ -360,34 +453,30 @@ export function approvePairing(agentId: string, code: string, channel: string =
360
453
  }
361
454
  }
362
455
 
363
- /** Revoke an approved sender from the allowlist */
364
- export function revokePairing(agentId: string, senderId: string, channel: string = 'telegram'): { success: boolean; error?: string } {
456
+ /**
457
+ * Revoke an approved sender from the allowlist.
458
+ *
459
+ * There is no CLI or RPC for this: `openclaw pairing` exposes only `approve`, `list`
460
+ * and `help`, and `channels.pairing.*` has no remove (verified on 2026.9.3). The
461
+ * store's own writer is called instead (`removeChannelAllowFromStoreEntry`, found by
462
+ * name — see PAIRING_STORE_SCRIPT). On a release that no longer exposes it, the
463
+ * failure is explicit: nothing is silently left unchanged (which is what the old
464
+ * `openclaw pairing revoke` probe did).
465
+ */
466
+ export async function revokePairing(agentId: string, senderId: string, channel: string = 'telegram'): Promise<{ success: boolean; error?: string }> {
365
467
  const container = findContainerById(agentId);
366
468
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
367
-
368
- try {
369
- // Try native revoke command first, fall back to direct file manipulation
370
- const nativeOut = containerExecNoFail(
371
- container,
372
- `openclaw pairing revoke ${channel} ${escapeShellArg(senderId)} 2>&1`,
373
- );
374
-
375
- if (nativeOut && !nativeOut.includes('Unknown command') && !nativeOut.includes('not found')) {
376
- return { success: true };
377
- }
378
-
379
- // Fallback: remove from credentials file directly
380
- const filePath = pairingAllowFile(channel);
381
- const fallbackOut = containerExecNoFail(
382
- container,
383
- `node -e "const p='${filePath}';try{const d=JSON.parse(require('fs').readFileSync(p,'utf8'));d.allowFrom=(d.allowFrom||[]).filter(e=>e!=='${senderId.replace(/'/g, "'\\''")}');require('fs').writeFileSync(p,JSON.stringify(d,null,2),'utf8')}catch(e){console.error('revoke fallback error:',e.message)}"`,
384
- );
385
- if (fallbackOut && fallbackOut.includes('revoke fallback error:')) {
386
- return { success: false, error: fallbackOut };
387
- }
388
-
389
- return { success: true };
390
- } catch (e) {
391
- return { success: false, error: (e as Error).message };
469
+ if (!isSafeChannel(channel)) return { success: false, error: 'Invalid channel id' };
470
+
471
+ const result = await runPairingStore(container, 'remove', channel, senderId);
472
+ if (result.status === 'ok') return { success: true };
473
+ if (result.status === 'absent') return { success: true }; // already gone — the desired state
474
+ if (result.status === 'unsupported') {
475
+ return {
476
+ success: false,
477
+ error: 'This OpenClaw release does not expose its pairing store to Rev4a. '
478
+ + 'Revoke the sender with `/allowlist remove` from the chat.',
479
+ };
392
480
  }
481
+ return { success: false, error: result.error ?? 'Revoke failed' };
393
482
  }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Cold backup of an agent's volume.
3
+ *
4
+ * The agent is stopped, a detached helper container archives the volume, the archive
5
+ * is read back, and the agent is started again if it was running. A backup taken while
6
+ * OpenClaw runs can copy its SQLite files mid-write; this one cannot.
7
+ *
8
+ * The helper container is the job: `rev4a-backup-<agentId>`, labelled with the agent,
9
+ * the archive name, the kind and whether the agent was running. Everything needed to
10
+ * finish or clean up a job lives in Docker, not in Rev4a's memory, so a job survives a
11
+ * Rev4a restart: `reconcileColdBackups()` picks helpers up again at startup, and
12
+ * finishing is idempotent.
13
+ *
14
+ * The archive is written as `<file>.partial` and renamed only after `tar -tzf` has read
15
+ * all of it back and found `.openclaw/openclaw.json`, so a listed backup is complete.
16
+ * Design: plans/COLD-BACKUP-PLAN.md.
17
+ */
18
+ import { execFile } from 'child_process';
19
+ import { promisify } from 'util';
20
+ import { dockerFetch } from '@/lib/docker-socket';
21
+ import { isValidAgentId } from '@/lib/container';
22
+ import { runDocker } from '@/lib/agent-images';
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ export const BACKUP_VOLUME = 'rev4a-backups';
27
+ const HELPER_PREFIX = 'rev4a-backup-';
28
+ const LABEL_AGENT = 'rev4a.backup.agent';
29
+ const LABEL_FILE = 'rev4a.backup.file';
30
+ const LABEL_KIND = 'rev4a.backup.kind';
31
+ const LABEL_PREV = 'rev4a.backup.prev-state';
32
+ const LABEL_STARTED = 'rev4a.backup.started-at';
33
+ /** GNU tar counts checkpoints in records of 10 240 bytes of the uncompressed stream. */
34
+ const TAR_RECORD_BYTES = 10_240;
35
+ const CHECKPOINT_RECORDS = 2_000;
36
+ const WATCH_MS = 3_000;
37
+
38
+ export type ColdBackupKind = 'manual' | 'preupdate' | 'prerecreate';
39
+
40
+ export interface ColdBackupState {
41
+ agentId: string;
42
+ status: 'running' | 'succeeded' | 'failed';
43
+ file: string;
44
+ kind: ColdBackupKind;
45
+ startedAtMs: number | null;
46
+ finishedAtMs: number | null;
47
+ /** Uncompressed bytes to archive, from `du`; null until measured. */
48
+ totalBytes: number | null;
49
+ /** Uncompressed bytes archived so far. */
50
+ doneBytes: number | null;
51
+ percent: number | null;
52
+ /** Archive size once complete. */
53
+ archiveBytes: number | null;
54
+ error: string | null;
55
+ }
56
+
57
+ export class ColdBackupBusyError extends Error {}
58
+
59
+ const helperName = (agentId: string) => `${HELPER_PREFIX}${agentId}`;
60
+
61
+ /**
62
+ * The helper script, run by `sh` inside the agent's own image, which carries GNU tar,
63
+ * coreutils and gzip. `FILE` comes in through the environment, never through the
64
+ * script text. Machine-readable lines start with `REV4A `.
65
+ */
66
+ const SCRIPT = [
67
+ 'set -u',
68
+ 'P="/backup/$FILE.partial"',
69
+ 'rm -f "$P"',
70
+ 'TOTAL=$(du -sb --exclude=.npm/_cacache /source 2>/dev/null | cut -f1)',
71
+ 'echo "REV4A total ${TOTAL:-0}"',
72
+ 'AVAIL=$(( $(df -Pk /backup | awk \'NR==2 {print $4}\') * 1024 ))',
73
+ 'NEED=$(( ${TOTAL:-0} * 6 / 10 ))',
74
+ 'if [ "$AVAIL" -lt "$NEED" ]; then echo "REV4A error not enough space in rev4a-backups: about $NEED bytes needed, $AVAIL free"; exit 3; fi',
75
+ `if ! tar -czf "$P" --exclude=.npm/_cacache --checkpoint=${CHECKPOINT_RECORDS} --checkpoint-action=echo='REV4A progress %u' -C /source . ; then rm -f "$P"; echo "REV4A error tar failed"; exit 4; fi`,
76
+ 'if ! tar -tzf "$P" > /tmp/rev4a-list || ! grep -qx "./.openclaw/openclaw.json" /tmp/rev4a-list; then rm -f "$P"; echo "REV4A error verification failed: the archive does not read back or holds no .openclaw/openclaw.json"; exit 5; fi',
77
+ 'mv "$P" "/backup/$FILE" && chmod 644 "/backup/$FILE"',
78
+ 'echo "REV4A done $(stat -c %s "/backup/$FILE")"',
79
+ ].join('\n');
80
+
81
+ interface HelperInspect {
82
+ Id?: string;
83
+ State?: { Running?: boolean; ExitCode?: number; StartedAt?: string; FinishedAt?: string };
84
+ Config?: { Labels?: Record<string, string> };
85
+ }
86
+
87
+ async function inspectHelper(agentId: string): Promise<HelperInspect | null> {
88
+ try {
89
+ const info = await dockerFetch<HelperInspect>('GET', `/containers/${helperName(agentId)}/json`);
90
+ return typeof info?.Id === 'string' ? info : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Whether a cold backup of this agent is running (its helper exists). */
97
+ export async function isColdBackupRunning(agentId: string): Promise<boolean> {
98
+ const helper = await inspectHelper(agentId);
99
+ return !!helper?.State?.Running;
100
+ }
101
+
102
+ async function helperLog(agentId: string): Promise<string> {
103
+ try {
104
+ const { stdout, stderr } = await execFileAsync('docker', ['logs', '--tail', '400', helperName(agentId)], {
105
+ encoding: 'utf-8',
106
+ timeout: 15_000,
107
+ maxBuffer: 4 * 1024 * 1024,
108
+ });
109
+ return `${stdout}\n${stderr}`;
110
+ } catch {
111
+ return '';
112
+ }
113
+ }
114
+
115
+ function parseLog(log: string) {
116
+ let totalBytes: number | null = null;
117
+ let doneBytes: number | null = null;
118
+ let archiveBytes: number | null = null;
119
+ let error: string | null = null;
120
+ for (const raw of log.split('\n')) {
121
+ const at = raw.indexOf('REV4A ');
122
+ if (at === -1) continue;
123
+ const [kind, ...rest] = raw.slice(at + 6).trim().split(' ');
124
+ const value = rest.join(' ');
125
+ if (kind === 'total') totalBytes = Number(value) || null;
126
+ else if (kind === 'progress') doneBytes = (Number(value) || 0) * TAR_RECORD_BYTES;
127
+ else if (kind === 'done') archiveBytes = Number(value) || null;
128
+ else if (kind === 'error') error = value;
129
+ }
130
+ const percent = totalBytes && doneBytes !== null ? Math.min(99, Math.round((doneBytes / totalBytes) * 100)) : null;
131
+ return { totalBytes, doneBytes, archiveBytes, error, percent };
132
+ }
133
+
134
+ function stateFrom(agentId: string, helper: HelperInspect, log: string, status: ColdBackupState['status']): ColdBackupState {
135
+ const labels = helper.Config?.Labels ?? {};
136
+ const parsed = parseLog(log);
137
+ const finished = helper.State?.FinishedAt && !helper.State.FinishedAt.startsWith('0001') ? Date.parse(helper.State.FinishedAt) : null;
138
+ return {
139
+ agentId,
140
+ status,
141
+ file: labels[LABEL_FILE] ?? '',
142
+ kind: labels[LABEL_KIND] === 'preupdate' ? 'preupdate' : labels[LABEL_KIND] === 'prerecreate' ? 'prerecreate' : 'manual',
143
+ startedAtMs: Number(labels[LABEL_STARTED]) || null,
144
+ finishedAtMs: status === 'running' ? null : finished,
145
+ totalBytes: parsed.totalBytes,
146
+ doneBytes: parsed.doneBytes,
147
+ percent: status === 'succeeded' ? 100 : parsed.percent,
148
+ archiveBytes: parsed.archiveBytes,
149
+ error: status === 'failed' ? parsed.error ?? `backup helper exited with code ${helper.State?.ExitCode ?? '?'}` : null,
150
+ };
151
+ }
152
+
153
+ /** Results of jobs finished since this process started, for status reads after the helper is gone. */
154
+ const finished = new Map<string, ColdBackupState>();
155
+ /** Finishing in flight, so concurrent status reads finish a job once. */
156
+ const finishing = new Map<string, Promise<ColdBackupState>>();
157
+ const watching = new Set<string>();
158
+
159
+ async function startAgentIfItWasRunning(labels: Record<string, string>, agentId: string): Promise<void> {
160
+ if (labels[LABEL_PREV] !== 'running') return;
161
+ const container = await agentContainerName(agentId);
162
+ if (container) await runDocker(['start', container], { timeoutMs: 60_000 }).catch(() => {});
163
+ }
164
+
165
+ async function removePartial(file: string): Promise<void> {
166
+ if (!file) return;
167
+ await runDocker(
168
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, '-e', `FILE=${file}`, 'alpine', 'sh', '-c', 'rm -f "/backup/$FILE.partial"'],
169
+ { timeoutMs: 60_000 },
170
+ ).catch(() => {});
171
+ }
172
+
173
+ /** Finish an exited job: record its result, start the agent if it was running, remove the helper. */
174
+ async function finishJob(agentId: string, helper: HelperInspect): Promise<ColdBackupState> {
175
+ const inFlight = finishing.get(agentId);
176
+ if (inFlight) return inFlight;
177
+ const job = (async () => {
178
+ const log = await helperLog(agentId);
179
+ const ok = helper.State?.ExitCode === 0 && /REV4A done \d+/.test(log);
180
+ const state = stateFrom(agentId, helper, log, ok ? 'succeeded' : 'failed');
181
+ const labels = helper.Config?.Labels ?? {};
182
+ if (!ok) await removePartial(state.file);
183
+ await startAgentIfItWasRunning(labels, agentId);
184
+ await runDocker(['rm', '-f', helperName(agentId)], { timeoutMs: 30_000 }).catch(() => {});
185
+ finished.set(agentId, state);
186
+ return state;
187
+ })();
188
+ finishing.set(agentId, job);
189
+ try {
190
+ return await job;
191
+ } finally {
192
+ finishing.delete(agentId);
193
+ }
194
+ }
195
+
196
+ /** Current or last cold backup of an agent; finishes the job when its helper has exited. */
197
+ export async function coldBackupStatus(agentId: string): Promise<ColdBackupState | null> {
198
+ const helper = await inspectHelper(agentId);
199
+ if (!helper) return finishing.get(agentId) ?? finished.get(agentId) ?? null;
200
+ if (helper.State?.Running) return stateFrom(agentId, helper, await helperLog(agentId), 'running');
201
+ return finishJob(agentId, helper);
202
+ }
203
+
204
+ /** Polls a running job until it finishes, so the agent restarts even when nobody reads its status. */
205
+ function watch(agentId: string): void {
206
+ if (watching.has(agentId)) return;
207
+ watching.add(agentId);
208
+ const tick = async () => {
209
+ const state = await coldBackupStatus(agentId).catch(() => null);
210
+ if (state?.status === 'running') {
211
+ setTimeout(() => { void tick(); }, WATCH_MS);
212
+ } else {
213
+ watching.delete(agentId);
214
+ }
215
+ };
216
+ setTimeout(() => { void tick(); }, WATCH_MS);
217
+ }
218
+
219
+ interface ContainerInspect {
220
+ Name?: string;
221
+ Image?: string;
222
+ State?: { Running?: boolean };
223
+ }
224
+
225
+ async function agentContainerName(agentId: string): Promise<string | null> {
226
+ const filters = encodeURIComponent(JSON.stringify({ label: [`AGENT_ID=${agentId}`] }));
227
+ const list = await dockerFetch<{ Names?: string[] }[]>('GET', `/containers/json?all=true&filters=${filters}`).catch(() => null);
228
+ const name = Array.isArray(list) ? list[0]?.Names?.[0] : undefined;
229
+ return name ? name.replace(/^\//, '') : null;
230
+ }
231
+
232
+ function timestamp(): string {
233
+ const d = new Date();
234
+ const pad = (n: number, w = 2) => String(n).padStart(w, '0');
235
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${pad(d.getMilliseconds(), 3)}`;
236
+ }
237
+
238
+ /**
239
+ * Start a cold backup. Returns once the helper runs; the archive is written in the
240
+ * background. `label` goes into the file name (the OpenClaw version, for a pre-update
241
+ * backup): `agent-<id>-preupdate-<label>-<ts>.tar.gz` or `agent-<id>-cold-<ts>.tar.gz`.
242
+ * Throws `ColdBackupBusyError` when a backup of this agent is already running.
243
+ */
244
+ export async function startColdBackup(
245
+ agentId: string,
246
+ /** `leaveStopped`: do not start the agent afterwards — the Update action recreates it next. */
247
+ opts: { kind: ColdBackupKind; label?: string; leaveStopped?: boolean },
248
+ ): Promise<{ file: string }> {
249
+ if (!isValidAgentId(agentId)) throw new Error('Invalid agent id');
250
+ if (await inspectHelper(agentId)) {
251
+ const current = await coldBackupStatus(agentId);
252
+ if (current?.status === 'running') throw new ColdBackupBusyError('A backup of this agent is already running');
253
+ }
254
+
255
+ const name = await agentContainerName(agentId);
256
+ if (!name) throw new Error(`No container found with AGENT_ID '${agentId}'`);
257
+ const container = await dockerFetch<ContainerInspect>('GET', `/containers/${encodeURIComponent(name)}/json`);
258
+ if (!container?.Image) throw new Error(`Container '${name}' could not be inspected`);
259
+ const volume = `agent-${agentId}-data`;
260
+ const vol = await dockerFetch<{ Name?: string }>('GET', `/volumes/${volume}`).catch(() => null);
261
+ if (vol?.Name !== volume) throw new Error(`No persistent volume found for agent '${agentId}'`);
262
+
263
+ const label = (opts.label ?? '').replace(/[^A-Za-z0-9._-]/g, '');
264
+ const file = opts.kind === 'preupdate'
265
+ ? `agent-${agentId}-preupdate-${label || 'unknown'}-${timestamp()}.tar.gz`
266
+ : opts.kind === 'prerecreate'
267
+ ? `agent-${agentId}-prerecreate-${timestamp()}.tar.gz`
268
+ : `agent-${agentId}-cold-${timestamp()}.tar.gz`;
269
+
270
+ const wasRunning = container.State?.Running === true;
271
+ if (wasRunning) {
272
+ await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 })
273
+ .catch(() => runDocker(['kill', name], { timeoutMs: 15_000 }));
274
+ }
275
+
276
+ try {
277
+ await runDocker([
278
+ 'run', '-d', '--name', helperName(agentId),
279
+ '--label', `${LABEL_AGENT}=${agentId}`,
280
+ '--label', `${LABEL_FILE}=${file}`,
281
+ '--label', `${LABEL_KIND}=${opts.kind}`,
282
+ '--label', `${LABEL_PREV}=${wasRunning && !opts.leaveStopped ? 'running' : 'stopped'}`,
283
+ '--label', `${LABEL_STARTED}=${Date.now()}`,
284
+ '-e', `FILE=${file}`,
285
+ '-v', `${volume}:/source:ro`,
286
+ '-v', `${BACKUP_VOLUME}:/backup`,
287
+ '--entrypoint', 'sh',
288
+ container.Image,
289
+ '-c', SCRIPT,
290
+ ], { timeoutMs: 60_000 });
291
+ } catch (e) {
292
+ await runDocker(['rm', '-f', helperName(agentId)], { timeoutMs: 30_000 }).catch(() => {});
293
+ if (wasRunning) await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
294
+ throw new Error(`Could not start the backup: ${(e as Error).message}`);
295
+ }
296
+
297
+ finished.delete(agentId);
298
+ watch(agentId);
299
+ return { file };
300
+ }
301
+
302
+ /** Cancel a running backup: the partial archive is removed and the agent restarted if it was running. */
303
+ export async function cancelColdBackup(agentId: string): Promise<boolean> {
304
+ const helper = await inspectHelper(agentId);
305
+ if (!helper?.State?.Running) return false;
306
+ const labels = helper.Config?.Labels ?? {};
307
+ await runDocker(['rm', '-f', helperName(agentId)], { timeoutMs: 30_000 }).catch(() => {});
308
+ await removePartial(labels[LABEL_FILE] ?? '');
309
+ await startAgentIfItWasRunning(labels, agentId);
310
+ finished.set(agentId, { ...stateFrom(agentId, helper, '', 'failed'), error: 'cancelled', finishedAtMs: Date.now() });
311
+ return true;
312
+ }
313
+
314
+ /** Wait for a job to finish, reporting progress. Resolves with the final state. */
315
+ export async function waitForColdBackup(
316
+ agentId: string,
317
+ opts: { onProgress?: (state: ColdBackupState) => void; pollMs?: number; timeoutMs?: number } = {},
318
+ ): Promise<ColdBackupState> {
319
+ const deadline = opts.timeoutMs ? Date.now() + opts.timeoutMs : null;
320
+ for (;;) {
321
+ const state = await coldBackupStatus(agentId);
322
+ if (!state) throw new Error('The backup job disappeared');
323
+ if (state.status !== 'running') return state;
324
+ opts.onProgress?.(state);
325
+ if (deadline && Date.now() >= deadline) {
326
+ // The helper is stuck: say so instead of hanging the caller (and the agent's
327
+ // busy lock) forever. The job is left alone — an operator can cancel it.
328
+ throw new Error(`The cold backup did not finish within ${Math.round(opts.timeoutMs! / 60_000)} minutes`);
329
+ }
330
+ await new Promise((r) => setTimeout(r, opts.pollMs ?? 2_000));
331
+ }
332
+ }
333
+
334
+ /** Agent ids with a backup helper, running or exited. */
335
+ export async function listColdBackupHelpers(): Promise<string[]> {
336
+ const filters = encodeURIComponent(JSON.stringify({ label: [LABEL_AGENT] }));
337
+ const list = await dockerFetch<{ Labels?: Record<string, string> }[]>('GET', `/containers/json?all=true&filters=${filters}`).catch(() => null);
338
+ return (Array.isArray(list) ? list : []).map((c) => c.Labels?.[LABEL_AGENT] ?? '').filter(Boolean);
339
+ }
340
+
341
+ /** Agent ids with a cold backup currently running (helper container still running). */
342
+ export async function listRunningColdBackups(): Promise<string[]> {
343
+ const filters = encodeURIComponent(JSON.stringify({ label: [LABEL_AGENT], status: ['running'] }));
344
+ const list = await dockerFetch<{ Labels?: Record<string, string> }[]>('GET', `/containers/json?all=true&filters=${filters}`).catch(() => null);
345
+ return (Array.isArray(list) ? list : []).map((c) => c.Labels?.[LABEL_AGENT] ?? '').filter(Boolean);
346
+ }
347
+
348
+ /** At startup: finish helpers that exited while Rev4a was down, and watch the ones still running. */
349
+ export async function reconcileColdBackups(): Promise<void> {
350
+ for (const agentId of await listColdBackupHelpers()) {
351
+ const state = await coldBackupStatus(agentId).catch(() => null);
352
+ if (state?.status === 'running') watch(agentId);
353
+ }
354
+ }