@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
@@ -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,7 +1,8 @@
1
1
  import { execSync } from 'child_process';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
- import { DB_FILE, AGENTS_TOKEN_FILE, MODELS_CONFIG_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
4
+ import { DB_FILE, AGENTS_TOKEN_FILE, templatesDir } from '@/lib/rev4a-paths';
5
+ import { loadOfferedModels } from '@/lib/model-catalogue';
5
6
  import CreatePageClient from './PageClient';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
@@ -33,26 +34,12 @@ async function getTemplates(): Promise<TemplateInfo[]> {
33
34
  }
34
35
 
35
36
  async function getModels(): Promise<ModelInfo[]> {
36
- try {
37
- const raw = fs.readFileSync(MODELS_CONFIG_FILE, 'utf-8');
38
- const config = JSON.parse(raw);
39
- const allModels: ModelInfo[] = (config.models || []).filter((m: { enabled: boolean }) => m.enabled !== false);
40
-
41
- let configuredProviders: string[] = [];
42
- try {
43
- const keysRaw = fs.readFileSync(PROVIDER_KEYS_FILE, 'utf-8');
44
- configuredProviders = Object.keys(JSON.parse(keysRaw));
45
- } catch { /* no keys configured yet */ }
46
-
47
- if (configuredProviders.length > 0) {
48
- return allModels.filter((m) => configuredProviders.includes(m.provider));
49
- }
50
-
51
- // No providers configured — show no models
52
- return [];
53
- } catch {
54
- return [];
55
- }
37
+ // Through the shared definition, not a raw file read. Reading the bundle
38
+ // directly skipped model-overrides.json, so a model the operator had switched
39
+ // off on the Gateway page was still offered here — and one they had switched
40
+ // on was missing. On the production host that was 58 models listed against 62
41
+ // correct: two offered that should not be, six absent that should be.
42
+ return loadOfferedModels().map((m) => ({ id: m.id, name: m.name, provider: m.provider }));
56
43
  }
57
44
 
58
45
  async function getUsedNames(): Promise<string[]> {
@@ -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 });