@flame0510/project-aether 1.3.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 (76) hide show
  1. package/README.md +1 -0
  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 +4 -1
  9. package/app/agents/PageClient.tsx +629 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/api/agents/[id]/backup/route.ts +26 -69
  13. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  14. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  15. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  16. package/app/api/agents/[id]/devices/route.ts +126 -0
  17. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  18. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  19. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  20. package/app/api/agents/[id]/recreate/route.ts +33 -163
  21. package/app/api/agents/[id]/restart/route.ts +5 -0
  22. package/app/api/agents/[id]/restore/route.ts +40 -70
  23. package/app/api/agents/[id]/route.ts +38 -150
  24. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  25. package/app/api/agents/[id]/update/route.ts +50 -0
  26. package/app/api/agents/activity-summary/route.ts +67 -0
  27. package/app/api/agents/create/route.ts +32 -88
  28. package/app/api/agents/devices-summary/route.ts +37 -0
  29. package/app/api/agents/download-image/route.ts +16 -9
  30. package/app/api/agents/image-status/route.ts +31 -111
  31. package/app/api/agents/route.ts +25 -49
  32. package/app/api/agents/token/route.ts +33 -10
  33. package/app/api/assistant/route.ts +2 -2
  34. package/app/api/gateway/agent/route.ts +14 -0
  35. package/app/api/gateway/provider/balance/route.ts +5 -2
  36. package/app/api/gateway/sync.ts +97 -14
  37. package/app/api/setup/agent-image/route.ts +14 -42
  38. package/app/components/DashboardToolbar.tsx +1 -1
  39. package/app/gateway/PageClient.tsx +27 -32
  40. package/bin/rev4a.js +43 -41
  41. package/daemon.js +6 -6
  42. package/docs/ARCHITECTURE.md +95 -9
  43. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  44. package/docs/REV4A.md +54 -17
  45. package/docs/dev/API-REFERENCE.md +554 -100
  46. package/docs/dev/DATABASE.md +96 -0
  47. package/docs/dev/GATEWAY.md +21 -6
  48. package/docs/rag/DATA-FRESHNESS.md +6 -4
  49. package/docs/rag/GLOSSARY.md +12 -3
  50. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  51. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  52. package/instrumentation.ts +43 -0
  53. package/lib/agent-busy.ts +21 -0
  54. package/lib/agent-devices.ts +361 -0
  55. package/lib/agent-edit-state.ts +108 -0
  56. package/lib/agent-edit.ts +157 -0
  57. package/lib/agent-images.ts +375 -0
  58. package/lib/agent-ports-server.ts +27 -0
  59. package/lib/agent-ports.ts +68 -0
  60. package/lib/agent-recreate-state.ts +108 -0
  61. package/lib/agent-recreate.ts +305 -0
  62. package/lib/agent-restore-state.ts +107 -0
  63. package/lib/agent-restore.ts +135 -0
  64. package/lib/agent-setup.ts +66 -17
  65. package/lib/agent-update-state.ts +122 -0
  66. package/lib/agent-update.ts +448 -0
  67. package/lib/agent-versions.json +14 -0
  68. package/lib/agent-versions.ts +80 -0
  69. package/lib/buildAgentImage.ts +88 -290
  70. package/lib/channelManager.ts +149 -102
  71. package/lib/cold-backup.ts +354 -0
  72. package/lib/credentials/delivery.ts +3 -3
  73. package/lib/db-bootstrap.mjs +76 -0
  74. package/lib/docker-utils.ts +3 -3
  75. package/lib/provider-balance.ts +33 -12
  76. package/package.json +1 -1
@@ -0,0 +1,300 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The OpenClaw version of one agent, the Update action and its Rollback, in the
5
+ * agent's detail panel.
6
+ *
7
+ * An update stops the agent, takes a cold backup, recreates it on the newer version
8
+ * (which migrates its data one way), then checks that no transcript events or cron jobs
9
+ * went missing. A rollback restores that backup on the previous version. Data comes from
10
+ * `/api/agents/[id]/update`, polled every 2 s while either runs. See lib/agent-update.ts.
11
+ */
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { Badge, Button, ConfirmModal } from '../components/ui';
14
+ import { SkeletonLines } from '../components/Skeleton';
15
+
16
+ type UpdateStatus =
17
+ | 'pending' | 'backing_up' | 'migrating' | 'verifying' | 'done'
18
+ | 'failed' | 'interrupted' | 'rolling_back' | 'rolled_back' | 'rollback_failed';
19
+
20
+ interface Verification {
21
+ sessions: number;
22
+ lostEvents: { session: string; before: number; after: number }[];
23
+ missingCronJobs: string[] | null;
24
+ ok: boolean;
25
+ }
26
+
27
+ interface UpdateView {
28
+ id: number;
29
+ status: UpdateStatus;
30
+ fromVersion: string | null;
31
+ toVersion: string;
32
+ backupFile: string | null;
33
+ backupPercent: number | null;
34
+ verification: Verification | null;
35
+ error: string | null;
36
+ startedAtMs: number;
37
+ finishedAtMs: number | null;
38
+ }
39
+
40
+ interface UpdateInfo {
41
+ currentVersion: string | null;
42
+ candidate: { fromVersion: string; toVersion: string } | null;
43
+ update: UpdateView | null;
44
+ }
45
+
46
+ type Action = 'update' | 'rollback';
47
+
48
+ const ACTIVE: UpdateStatus[] = ['pending', 'backing_up', 'migrating', 'verifying', 'rolling_back'];
49
+ const ROLLBACK_FROM: UpdateStatus[] = ['done', 'failed', 'interrupted', 'rollback_failed'];
50
+ const POLL_MS = 2_000;
51
+
52
+ const STEPS: { status: UpdateStatus; label: string }[] = [
53
+ { status: 'backing_up', label: 'Cold backup' },
54
+ { status: 'migrating', label: 'Start on the new version' },
55
+ { status: 'verifying', label: 'Check transcripts and cron jobs' },
56
+ ];
57
+
58
+ const metaStyle: React.CSSProperties = { fontSize: 11, color: 'var(--text-dim)', wordBreak: 'break-word' };
59
+
60
+ function when(ms: number | null): string {
61
+ return ms ? new Date(ms).toLocaleString() : '';
62
+ }
63
+
64
+ export default function UpdateSection({
65
+ agentId,
66
+ running,
67
+ onChanged,
68
+ onStarted,
69
+ }: {
70
+ agentId: string;
71
+ running: boolean;
72
+ /** An update or rollback finished: the parent reloads the agent (new image, new version). */
73
+ onChanged?: () => void;
74
+ /** The update or rollback started — show the UPDATING chip at once. */
75
+ onStarted?: () => void;
76
+ }) {
77
+ const [info, setInfo] = useState<UpdateInfo | null>(null);
78
+ const [loadError, setLoadError] = useState<string | null>(null);
79
+ const [busy, setBusy] = useState<Action | null>(null);
80
+ const busyRef = useRef<Action | null>(null);
81
+ const [confirming, setConfirming] = useState<Action | null>(null);
82
+ const [actionError, setActionError] = useState<string | null>(null);
83
+ const abortRef = useRef<AbortController | null>(null);
84
+ const lastStatus = useRef<UpdateStatus | null>(null);
85
+
86
+ const load = useCallback(async () => {
87
+ abortRef.current?.abort();
88
+ const controller = new AbortController();
89
+ abortRef.current = controller;
90
+ try {
91
+ const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/update`, { signal: controller.signal, cache: 'no-store' });
92
+ if (abortRef.current !== controller) return;
93
+ const data = (await res.json().catch(() => null)) as (UpdateInfo & { error?: string }) | null;
94
+ if (abortRef.current !== controller) return;
95
+ if (!res.ok || !data) {
96
+ setLoadError(data?.error ?? `Could not load the update state (HTTP ${res.status})`);
97
+ return;
98
+ }
99
+ setInfo(data);
100
+ setLoadError(null);
101
+ const status = data.update?.status ?? null;
102
+ if (lastStatus.current && ACTIVE.includes(lastStatus.current) && status && !ACTIVE.includes(status)) onChanged?.();
103
+ lastStatus.current = status;
104
+ } catch (e) {
105
+ if ((e as Error)?.name !== 'AbortError' && abortRef.current === controller) setLoadError('Could not load the update state');
106
+ }
107
+ }, [agentId, onChanged]);
108
+
109
+ const update = info?.update ?? null;
110
+ const active = !!update && ACTIVE.includes(update.status);
111
+
112
+ useEffect(() => {
113
+ setInfo(null);
114
+ setLoadError(null);
115
+ lastStatus.current = null;
116
+ void load();
117
+ return () => abortRef.current?.abort();
118
+ }, [load]);
119
+
120
+ useEffect(() => {
121
+ if (!active) return;
122
+ const timer = setInterval(() => { void load(); }, POLL_MS);
123
+ return () => clearInterval(timer);
124
+ }, [active, load]);
125
+
126
+ const run = async (action: Action) => {
127
+ setConfirming(null);
128
+ if (busyRef.current) return;
129
+ busyRef.current = action;
130
+ setBusy(action);
131
+ setActionError(null);
132
+ try {
133
+ const res = action === 'update'
134
+ ? await fetch(`/api/agents/${encodeURIComponent(agentId)}/update`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({ version: info?.candidate?.toVersion }),
138
+ })
139
+ : await fetch(`/api/agents/${encodeURIComponent(agentId)}/update/rollback`, { method: 'POST' });
140
+ if (!res.ok) {
141
+ const data = (await res.json().catch(() => null)) as { error?: string } | null;
142
+ setActionError(data?.error ?? `Could not start the ${action} (HTTP ${res.status})`);
143
+ } else {
144
+ onStarted?.();
145
+ }
146
+ } catch {
147
+ setActionError(`Could not start the ${action}`);
148
+ }
149
+ busyRef.current = null;
150
+ setBusy(null);
151
+ void load();
152
+ };
153
+
154
+ if (!info && !loadError) return <SkeletonLines count={2} />;
155
+
156
+ const candidate = info?.candidate ?? null;
157
+ const stepIndex = update ? STEPS.findIndex((s) => s.status === update.status) : -1;
158
+ const canRollBack = !!update && !active && ROLLBACK_FROM.includes(update.status) && !!update.backupFile && !!update.fromVersion;
159
+
160
+ return (
161
+ <div>
162
+ {loadError && (
163
+ <div style={{ margin: '0 0 6px' }}>
164
+ <Badge tone="danger"><span style={{ fontSize: 10 }}>{loadError}</span></Badge>
165
+ </div>
166
+ )}
167
+
168
+ <div style={{ fontSize: 12, color: 'var(--text)', marginBottom: 6 }}>
169
+ OpenClaw {info?.currentVersion ?? 'version unknown'}
170
+ </div>
171
+
172
+ {update && active && update.status !== 'rolling_back' && (
173
+ <div style={{ margin: '6px 0 10px' }}>
174
+ <div style={metaStyle}>Updating OpenClaw {update.fromVersion} → {update.toVersion}. The agent is offline until it finishes.</div>
175
+ {STEPS.map((step, i) => {
176
+ const state = update.status === 'pending' ? 'waiting' : i < stepIndex ? 'done' : i === stepIndex ? 'running' : 'waiting';
177
+ return (
178
+ <div key={step.status} style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '3px 0', fontSize: 11 }}>
179
+ <span style={{ width: 14, color: state === 'done' ? 'var(--green)' : state === 'running' ? 'var(--violet)' : 'var(--text-dim)' }}>
180
+ {state === 'done' ? '✓' : state === 'running' ? '●' : '○'}
181
+ </span>
182
+ <span style={{ color: state === 'waiting' ? 'var(--text-dim)' : 'var(--text)' }}>
183
+ {step.label}
184
+ {state === 'running' && step.status === 'backing_up' && update.backupPercent !== null ? ` — ${update.backupPercent}%` : ''}
185
+ </span>
186
+ </div>
187
+ );
188
+ })}
189
+ </div>
190
+ )}
191
+
192
+ {update && update.status === 'rolling_back' && (
193
+ <div style={{ margin: '6px 0 10px', ...metaStyle }}>
194
+ Rolling back to OpenClaw {update.fromVersion}: restoring {update.backupFile}. The agent is offline until it finishes.
195
+ </div>
196
+ )}
197
+
198
+ {update && update.status === 'done' && (
199
+ <div style={{ margin: '6px 0 10px' }}>
200
+ <Badge tone="success"><span style={{ fontSize: 10 }}>Updated from OpenClaw {update.fromVersion} to {update.toVersion}</span></Badge>
201
+ <div style={metaStyle}>
202
+ {when(update.finishedAtMs)}
203
+ {update.verification ? ` · ${update.verification.sessions} session(s) checked` : ''}
204
+ {update.backupFile ? ` · pre-update backup ${update.backupFile}` : ''}
205
+ </div>
206
+ </div>
207
+ )}
208
+
209
+ {update && (update.status === 'failed' || update.status === 'interrupted') && (
210
+ <div style={{ margin: '6px 0 10px' }}>
211
+ <Badge tone="danger">
212
+ <span style={{ fontSize: 10 }}>
213
+ Update to OpenClaw {update.toVersion} {update.status === 'interrupted' ? 'was interrupted' : 'failed'}
214
+ </span>
215
+ </Badge>
216
+ {update.error && <div style={metaStyle}>{update.error}</div>}
217
+ {update.verification && update.verification.lostEvents.length > 0 && (
218
+ <div style={metaStyle}>
219
+ {update.verification.lostEvents.map((l) => `${l.session}: ${l.before} → ${l.after}`).join(' · ')}
220
+ </div>
221
+ )}
222
+ {update.backupFile && <div style={metaStyle}>Pre-update backup: {update.backupFile}</div>}
223
+ </div>
224
+ )}
225
+
226
+ {update && update.status === 'rolled_back' && (
227
+ <div style={{ margin: '6px 0 10px' }}>
228
+ <Badge tone="warning"><span style={{ fontSize: 10 }}>Rolled back to OpenClaw {update.fromVersion}</span></Badge>
229
+ <div style={metaStyle}>{when(update.finishedAtMs)} · restored {update.backupFile}</div>
230
+ </div>
231
+ )}
232
+
233
+ {update && update.status === 'rollback_failed' && (
234
+ <div style={{ margin: '6px 0 10px' }}>
235
+ <Badge tone="danger"><span style={{ fontSize: 10 }}>Rollback to OpenClaw {update.fromVersion} failed</span></Badge>
236
+ {update.error && <div style={metaStyle}>{update.error}</div>}
237
+ </div>
238
+ )}
239
+
240
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
241
+ {candidate && !active && (
242
+ <Button
243
+ variant="primary"
244
+ size="sm"
245
+ loading={busy === 'update'}
246
+ disabled={busy !== null || !running}
247
+ title={running ? undefined : 'Start the agent first: its data is counted while it runs'}
248
+ onClick={() => setConfirming('update')}
249
+ >
250
+ {busy === 'update' ? 'Starting…' : `Update to ${candidate.toVersion}`}
251
+ </Button>
252
+ )}
253
+ {canRollBack && update && (
254
+ <Button
255
+ variant="secondary"
256
+ size="sm"
257
+ loading={busy === 'rollback'}
258
+ disabled={busy !== null}
259
+ onClick={() => setConfirming('rollback')}
260
+ >
261
+ {busy === 'rollback' ? 'Starting…' : `Roll back to ${update.fromVersion}`}
262
+ </Button>
263
+ )}
264
+ </div>
265
+
266
+ {actionError && (
267
+ <div style={{ margin: '6px 0' }}>
268
+ <Badge tone="danger"><span style={{ fontSize: 10 }}>{actionError}</span></Badge>
269
+ </div>
270
+ )}
271
+
272
+ <ConfirmModal
273
+ open={confirming === 'update'}
274
+ onClose={() => setConfirming(null)}
275
+ onConfirm={() => { void run('update'); }}
276
+ title={candidate ? `Update to OpenClaw ${candidate.toVersion}?` : 'Update?'}
277
+ message={
278
+ 'The agent goes offline, its volume is backed up while stopped, and it starts again on the new version, ' +
279
+ 'which migrates its data. The migration cannot be undone in place: going back restores this backup, ' +
280
+ 'and anything the agent does after the update is lost.'
281
+ }
282
+ confirmLabel="Update"
283
+ cancelLabel="Cancel"
284
+ />
285
+ <ConfirmModal
286
+ open={confirming === 'rollback'}
287
+ onClose={() => setConfirming(null)}
288
+ onConfirm={() => { void run('rollback'); }}
289
+ title={update?.fromVersion ? `Roll back to OpenClaw ${update.fromVersion}?` : 'Roll back?'}
290
+ message={
291
+ `The agent goes offline, its volume is replaced by the pre-update backup ${update?.backupFile ?? ''} ` +
292
+ 'and it starts again on the previous version. Everything the agent did after that backup is lost.'
293
+ }
294
+ confirmLabel="Roll back"
295
+ cancelLabel="Cancel"
296
+ tone="danger"
297
+ />
298
+ </div>
299
+ );
300
+ }
@@ -3,6 +3,8 @@
3
3
  import { useState, useMemo, useEffect } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { Button, Input, TemplateOption } from '../../components/ui';
6
+ import { OpenControlUiButton } from '../BrowserAccessSection';
7
+ import { findAvailablePortBlock, validatePortInput } from '@/lib/agent-ports';
6
8
 
7
9
  interface TemplateInfo {
8
10
  id: string;
@@ -26,19 +28,6 @@ type Step = 'template' | 'config' | 'creating' | 'done' | 'error';
26
28
 
27
29
  const REV4A_PORT = 3740;
28
30
 
29
- function findFirstFreeBlock(usedPorts: number[]): number | null {
30
- const used = new Set(usedPorts);
31
- for (let base = 3700; base + 9 <= 3799; base += 10) {
32
- if (base <= REV4A_PORT && REV4A_PORT <= base + 9) continue;
33
- const block = Array.from({ length: 10 }, (_, i) => base + i);
34
- if (block.every((p) => !used.has(p))) return base;
35
- }
36
- // Fallback beyond range
37
- for (let base = 3800; ; base += 10) {
38
- const block = Array.from({ length: 10 }, (_, i) => base + i);
39
- if (block.every((p) => !used.has(p))) return base;
40
- }
41
- }
42
31
 
43
32
  // ─── Toast ───
44
33
  function Toast({ message, type, onClose }: { message: string; type: 'success' | 'error'; onClose: () => void }) {
@@ -77,16 +66,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
77
66
  const [fallbacks] = useState<string[]>([]);
78
67
  const [submitting, setSubmitting] = useState(false);
79
68
  const [errorMsg, setErrorMsg] = useState('');
80
- const [result, setResult] = useState<{ name: string; containerId: string; port: number; controlToken: string } | null>(null);
69
+ const [result, setResult] = useState<{ containerName: string; displayName: string; containerId: string; port: number } | null>(null);
81
70
  const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
82
71
 
83
- // The agent's Control UI URL, composed here rather than server-side: whatever
84
- // hostname reached this dashboard is one that resolves and routes for this
85
- // user, which the server can only guess at. Null until the create succeeds.
86
- const controlUrl = result && typeof window !== 'undefined'
87
- ? `http://${window.location.hostname}:${result.port}#token=${encodeURIComponent(result.controlToken)}`
88
- : null;
89
-
90
72
  const handleSelectTemplate = (id: string) => {
91
73
  setSelectedTemplate(id);
92
74
  setAgentName('');
@@ -95,7 +77,7 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
95
77
  setPortRange('3700-3709');
96
78
  } else {
97
79
  // Simple client-side block finder
98
- let block = findFirstFreeBlock(initialUsedPorts);
80
+ const block = initialUsedPorts.length ? findAvailablePortBlock(new Set(initialUsedPorts)) : null;
99
81
  setPortRange(block ? `${block}-${block + 9}` : '');
100
82
  }
101
83
  };
@@ -108,26 +90,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
108
90
  }, [agentName, usedNames]);
109
91
 
110
92
  const portError = useMemo(() => {
111
- const v = portRange.trim();
112
- if (!v) return '';
113
- if (v.includes('-')) {
114
- const m = v.match(/^(\d{1,5})-(\d{1,5})$/);
115
- if (!m) return 'Use format: 3700-3709';
116
- const start = parseInt(m[1], 10);
117
- const end = parseInt(m[2], 10);
118
- if (start < 1 || end > 65535) return 'Ports 1-65535';
119
- if (end <= start) return 'End port must be greater than start';
120
- for (let p = start; p <= end; p++) {
121
- if (initialUsedPorts.includes(p)) return `Port ${p} is already in use`;
122
- if (p === 3740) return 'Port 3740 is reserved';
123
- }
124
- return '';
125
- }
126
- const p = parseInt(v, 10);
127
- if (isNaN(p) || p < 1 || p > 65535) return 'Port must be between 1 and 65535';
128
- if (p === 3740) return 'Port 3740 is reserved';
129
- if (initialUsedPorts.includes(p)) return 'Port is already in use';
130
- return '';
93
+ if (!portRange.trim()) return '';
94
+ const res = validatePortInput(portRange, new Set(initialUsedPorts));
95
+ return res.valid ? '' : res.error;
131
96
  }, [portRange, initialUsedPorts]);
132
97
 
133
98
  const canProceed = (): boolean => {
@@ -394,14 +359,11 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
394
359
  Container: <span style={{ color: 'var(--text)', fontFamily: 'var(--font-mono-stack)' }}>{result.containerId}</span>
395
360
  </div>
396
361
  <div style={{ fontSize: 13, color: '#888', marginBottom: 4 }}>
397
- Name: <span style={{ color: 'var(--text)' }}>{result.name}</span>
362
+ Name: <span style={{ color: 'var(--text)' }}>{result.displayName || result.containerName}</span>
363
+ </div>
364
+ <div style={{ marginTop: 8 }}>
365
+ <OpenControlUiButton agentId={result.containerName} label="Open Control UI ↗" />
398
366
  </div>
399
- {controlUrl && (
400
- <div style={{ fontSize: 13, color: '#888' }}>
401
- Access: <a href={controlUrl} target="_blank" rel="noopener noreferrer"
402
- style={{ color: '#60a5fa', textDecoration: 'underline' }}>{controlUrl}</a>
403
- </div>
404
- )}
405
367
  </div>
406
368
  <div style={{ display: 'flex', gap: 12 }}>
407
369
  <Button variant="ghost" size="md" onClick={() => { setToast(null); router.push('/agents'); }} style={{ flex: 1 }}>
@@ -1,36 +1,12 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { execSync } from 'child_process';
3
2
  import { isValidAgentId } from '@/lib/container';
4
3
  import { requireAuthJWT } from '@/lib/rev4a-auth';
4
+ import { runDocker } from '@/lib/agent-images';
5
5
 
6
6
  export const dynamic = 'force-dynamic';
7
7
 
8
8
  const BACKUP_VOLUME = 'rev4a-backups';
9
9
 
10
- /**
11
- * Determine the volume name for an agent by checking if it exists.
12
- */
13
- function agentVolumeName(agentName: string): string | null {
14
- try {
15
- const vols = execSync(
16
- "docker volume ls --format '{{.Name}}'",
17
- { encoding: 'utf-8', timeout: 5000 },
18
- ).trim().split('\n').filter(Boolean);
19
- const needle = `agent-${agentName}-data`;
20
- return vols.includes(needle) ? needle : null;
21
- } catch {
22
- return null;
23
- }
24
- }
25
-
26
- /** Format a timestamp as YYYY-MM-DD_HHmmssSSS (ms precision avoids same-second collisions). */
27
- function ts(): string {
28
- const d = new Date();
29
- const pad = (n: number) => String(n).padStart(2, '0');
30
- const ms = String(d.getMilliseconds()).padStart(3, '0');
31
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${ms}`;
32
- }
33
-
34
10
  /** Convert bytes to human-readable size. */
35
11
  function fmtSize(bytes: number): string {
36
12
  if (bytes < 1024) return `${bytes} B`;
@@ -39,40 +15,6 @@ function fmtSize(bytes: number): string {
39
15
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
40
16
  }
41
17
 
42
- /**
43
- * POST /api/agents/[id]/backup
44
- * Create a backup of the agent's persistent volume.
45
- */
46
- export async function POST(request: Request,
47
- { params }: { params: Promise<{ id: string }> },
48
- ): Promise<NextResponse> {
49
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
50
- try {
51
- const { id } = await params;
52
- if (!id) return NextResponse.json({ error: 'Agent name required' }, { status: 400 });
53
- if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
54
-
55
- const volume = agentVolumeName(id);
56
- if (!volume) {
57
- return NextResponse.json({ error: `No persistent volume found for agent '${id}'` }, { status: 404 });
58
- }
59
-
60
- const filename = `agent-${id}-${ts()}.tar.gz`;
61
-
62
- execSync(
63
- `docker run --rm ` +
64
- `-v ${volume}:/source:ro ` +
65
- `-v ${BACKUP_VOLUME}:/backup ` +
66
- `alpine sh -c 'tar czf /backup/${filename} --exclude=.npm/_cacache -C /source . && chmod 644 /backup/${filename}'`,
67
- { timeout: 60000, stdio: 'pipe' },
68
- );
69
-
70
- return NextResponse.json({ success: true, file: filename });
71
- } catch (e: unknown) {
72
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
73
- }
74
- }
75
-
76
18
  interface BackupFile {
77
19
  name: string;
78
20
  size: string;
@@ -96,12 +38,22 @@ export async function GET(request: Request,
96
38
 
97
39
  const prefix = `agent-${id}-`;
98
40
 
99
- const raw = execSync(
100
- `docker run --rm -v ${BACKUP_VOLUME}:/backup alpine sh -c 'stat -c "%s %Y %n" /backup/${prefix}*.tar.gz 2>/dev/null || true'`,
101
- { timeout: 10000, encoding: 'utf-8' },
102
- ).trim();
41
+ // $PREFIX stays unquoted so the shell expands the glob; the id is validated, so
42
+ // the pattern carries no shell metacharacters. The archive name reaches the
43
+ // command through the environment, never as shell syntax.
44
+ const lines: string[] = [];
45
+ let failed: unknown = null;
46
+ await runDocker(
47
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c',
48
+ 'stat -c "%s %Y %n" /backup/$PREFIX*.tar.gz 2>/dev/null || true'],
49
+ { timeoutMs: 30_000, env: { PREFIX: prefix }, onLine: (line) => lines.push(line.trim()) },
50
+ ).catch((e) => { failed = e; });
51
+ // A failed list must not read as "no backups": under load the container start has
52
+ // been seen to pass 10 s, and an empty array would look like an empty volume.
53
+ if (failed) {
54
+ return NextResponse.json({ error: `Could not list backups: ${(failed as Error).message}` }, { status: 502 });
55
+ }
103
56
 
104
- const lines = raw.split('\n').filter(Boolean);
105
57
  const backups: BackupFile[] = [];
106
58
 
107
59
  for (const line of lines) {
@@ -138,6 +90,10 @@ export async function GET(request: Request,
138
90
  /**
139
91
  * DELETE /api/agents/[id]/backup?file=agent-xxx-2026-07-11_2115.tar.gz
140
92
  * Remove a specific backup file.
93
+ *
94
+ * The file name reaches the container through its environment, never as shell
95
+ * syntax, and the name is validated against a strict character set first: only
96
+ * `agent-<id>-<timestamp>.tar.gz` shapes are accepted.
141
97
  */
142
98
  export async function DELETE(request: Request,
143
99
  { params }: { params: Promise<{ id: string }> },
@@ -152,14 +108,15 @@ export async function DELETE(request: Request,
152
108
  const file = url.searchParams.get('file');
153
109
  if (!file) return NextResponse.json({ error: 'file query param required' }, { status: 400 });
154
110
 
155
- // Safety: only allow deleting agent-specific files, reject path traversal
156
- if (!file.startsWith(`agent-${id}-`) || !file.endsWith('.tar.gz') || file.includes('/') || file.includes('..')) {
111
+ // Only this agent's archives, and only those shapes; no separators, no `..`,
112
+ // nothing that could carry shell syntax.
113
+ if (!file.startsWith(`agent-${id}-`) || !/^[A-Za-z0-9._-]+\.tar\.gz$/.test(file)) {
157
114
  return NextResponse.json({ error: 'Invalid backup file name' }, { status: 400 });
158
115
  }
159
116
 
160
- execSync(
161
- `docker run --rm -v ${BACKUP_VOLUME}:/backup alpine sh -c 'rm -f /backup/${file}'`,
162
- { timeout: 10000, stdio: 'pipe' },
117
+ await runDocker(
118
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'rm -f "/backup/$FILE"'],
119
+ { timeoutMs: 30_000, env: { FILE: file } },
163
120
  );
164
121
 
165
122
  return NextResponse.json({ success: true });
@@ -15,7 +15,7 @@ export async function GET(
15
15
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
16
16
  const { id } = await params;
17
17
  const channel = getChannel(request);
18
- const result = getPairings(id, channel);
18
+ const result = await getPairings(id, channel);
19
19
  return NextResponse.json(result);
20
20
  }
21
21
 
@@ -34,7 +34,7 @@ export async function POST(
34
34
  return NextResponse.json({ error: 'Pairing code is required' }, { status: 400 });
35
35
  }
36
36
 
37
- const result = approvePairing(id, body.code.trim(), channel);
37
+ const result = await approvePairing(id, body.code.trim(), channel);
38
38
 
39
39
  if (!result.success) {
40
40
  return NextResponse.json({ error: result.error }, { status: 404 });
@@ -59,7 +59,7 @@ export async function DELETE(
59
59
  return NextResponse.json({ error: 'senderId query param is required' }, { status: 400 });
60
60
  }
61
61
 
62
- const result = revokePairing(id, senderId, channel);
62
+ const result = await revokePairing(id, senderId, channel);
63
63
 
64
64
  if (!result.success) {
65
65
  return NextResponse.json({ error: result.error }, { status: 404 });
@@ -18,7 +18,7 @@ export async function PUT(
18
18
  return NextResponse.json({ error: 'botToken is required' }, { status: 400 });
19
19
  }
20
20
 
21
- const result = setTelegramConfig(id, {
21
+ const result = await setTelegramConfig(id, {
22
22
  botToken: body.botToken.trim(),
23
23
  dmPolicy: body.dmPolicy,
24
24
  allowFrom: body.allowFrom,
@@ -40,7 +40,7 @@ export async function DELETE(request: NextRequest,
40
40
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
41
41
  const { id } = await params;
42
42
 
43
- const result = removeTelegram(id);
43
+ const result = await removeTelegram(id);
44
44
 
45
45
  if (!result.success) {
46
46
  return NextResponse.json({ error: result.error }, { status: 404 });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Cold backup of one agent's volume (lib/cold-backup.ts).
3
+ *
4
+ * POST start: the agent is stopped, archived, verified and started again if it
5
+ * was running. 202 `{ started: true, file }`; the work continues in a helper container.
6
+ * GET `{ backup }`: the running or last job, with progress; null when none.
7
+ * DELETE cancel the running job: the partial archive is removed and the agent
8
+ * restarted if it was running.
9
+ */
10
+ import { NextResponse, type NextRequest } from 'next/server';
11
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
12
+ import { isValidAgentId } from '@/lib/container';
13
+ import { ColdBackupBusyError, cancelColdBackup, coldBackupStatus, startColdBackup } from '@/lib/cold-backup';
14
+ import { agentBusyReason } from '@/lib/agent-busy';
15
+
16
+ export const dynamic = 'force-dynamic';
17
+
18
+ type Ctx = { params: Promise<{ id: string }> };
19
+
20
+ async function agentId(ctx: Ctx): Promise<string | NextResponse> {
21
+ const { id } = await ctx.params;
22
+ return isValidAgentId(id) ? id : NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
23
+ }
24
+
25
+ export async function POST(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
26
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
27
+ const id = await agentId(ctx);
28
+ if (id instanceof NextResponse) return id;
29
+ // One source for every long operation: an update, recreate, restore, edit or backup.
30
+ const busy = await agentBusyReason(id);
31
+ if (busy) return NextResponse.json({ error: busy }, { status: 409 });
32
+ try {
33
+ const { file } = await startColdBackup(id, { kind: 'manual' });
34
+ return NextResponse.json({ started: true, file }, { status: 202 });
35
+ } catch (e) {
36
+ const status = e instanceof ColdBackupBusyError ? 409 : /No container|No persistent volume/.test((e as Error).message) ? 404 : 500;
37
+ return NextResponse.json({ error: (e as Error).message }, { status });
38
+ }
39
+ }
40
+
41
+ export async function GET(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
42
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
43
+ const id = await agentId(ctx);
44
+ if (id instanceof NextResponse) return id;
45
+ return NextResponse.json({ backup: await coldBackupStatus(id) }, { headers: { 'Cache-Control': 'no-store' } });
46
+ }
47
+
48
+ export async function DELETE(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
49
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
50
+ const id = await agentId(ctx);
51
+ if (id instanceof NextResponse) return id;
52
+ const cancelled = await cancelColdBackup(id);
53
+ return cancelled
54
+ ? NextResponse.json({ cancelled: true })
55
+ : NextResponse.json({ error: 'No backup of this agent is running' }, { status: 409 });
56
+ }