@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
@@ -3,11 +3,10 @@ import { execSync } from 'child_process';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
6
- import { patchRev4aProvider } from '@/app/api/gateway/sync';
7
- import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
6
+ import { agentBusyReason } from '@/lib/agent-busy';
7
+ import { agentEditView, EditRefusedError, startAgentEdit } from '@/lib/agent-edit';
8
8
  import { DB_FILE } from '@/lib/rev4a-paths';
9
9
  import { requireAuthJWT } from '@/lib/rev4a-auth';
10
- import { waitForGatewayReady } from '@/lib/agent-readiness';
11
10
 
12
11
  const BACKUP_VOLUME = 'rev4a-backups';
13
12
 
@@ -30,6 +29,9 @@ export async function DELETE(request: Request,
30
29
  return NextResponse.json({ success: false, error: 'Invalid agent id' }, { status: 400 });
31
30
  }
32
31
 
32
+ const busy = await agentBusyReason(id);
33
+ if (busy) return NextResponse.json({ error: busy }, { status: 409 });
34
+
33
35
  // Only allow deletion of containers that have a matching AGENT_ID label
34
36
  const containerName = resolveAgentedContainer(id);
35
37
  if (!containerName) {
@@ -87,16 +89,14 @@ export async function DELETE(request: Request,
87
89
  }
88
90
  }
89
91
 
90
- function shellQuote(value: string): string {
91
- return `'${value.replace(/'/g, `'\\''`)}'`;
92
- }
93
-
94
92
  /**
95
93
  * PATCH /api/agents/[id]
96
94
  *
97
- * Update agent settings (display name, ports — future).
98
- * Recreates the container with updated env vars while preserving the
99
- * persistent volume, workspace, sessions, and all data.
95
+ * Edit an agent's display name and/or port range. The container is rebuilt on the image
96
+ * it already runs — **no backup**, the persistent volume is never touched — and the job
97
+ * runs in the background (`lib/agent-edit.ts`): `202` once it has started, then
98
+ * `GET /api/agents/[id]` reports the running or last edit so the panel can resume.
99
+ * While it runs, anything else that would touch the agent answers `409`.
100
100
  */
101
101
  export async function PATCH(
102
102
  request: Request,
@@ -115,149 +115,37 @@ export async function PATCH(
115
115
  return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
116
116
  }
117
117
 
118
- const { displayName, portRange } = body;
119
- if (!displayName && !portRange) {
120
- return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
121
- }
122
-
123
- if (displayName !== undefined && (!displayName || displayName.trim().length < 1)) {
124
- return NextResponse.json({ error: 'Display name must not be empty' }, { status: 400 });
125
- }
126
-
127
- // Resolve the container
128
- const containerName = resolveAgentedContainer(id);
129
- if (!containerName) {
130
- return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
131
- }
132
-
133
- // Inspect existing container
134
- let inspect: string;
135
- try {
136
- inspect = execSync(`docker inspect ${containerName}`, { timeout: 10000, encoding: 'utf-8' }).trim();
137
- } catch {
138
- return NextResponse.json({ error: `Container '${containerName}' not found` }, { status: 404 });
139
- }
140
-
141
- const info = JSON.parse(inspect);
142
- const [container] = info;
143
- const image = container.Config?.Image || 'openclaw-agent-base:latest';
144
- const currentEnv: string[] = container.Config?.Env || [];
145
-
146
- // Update AGENT_NAME in env vars
147
- let env = [...currentEnv];
148
- if (displayName) {
149
- const idx = env.findIndex((e: string) => e.startsWith('AGENT_NAME='));
150
- const newEntry = `AGENT_NAME=${displayName.trim()}`;
151
- if (idx >= 0) {
152
- env[idx] = newEntry;
153
- } else {
154
- env.push(newEntry);
155
- }
156
- }
157
-
158
- // Keep only relevant env vars
159
- const keepPrefixes = ['AGENT_', 'MODEL_', 'OPENCLAW_', 'TZ='];
160
- const envOpts = env
161
- .filter((e: string) => keepPrefixes.some((p) => e.startsWith(p)))
162
- .map((e: string) => `-e ${shellQuote(e)}`)
163
- .join(' ');
164
-
165
- // Labels
166
- const labels = container.Config?.Labels || {};
167
- const keepLabelKeys = ['AGENT_ID', 'traefik.', 'description', 'maintainer'];
168
- const labelOpts = Object.entries(labels)
169
- .filter(([k]) => keepLabelKeys.some((p) => k === p || k.startsWith(p)))
170
- .map(([k, v]) => `-l ${shellQuote(`${k}=${v}`)}`)
171
- .join(' ');
172
-
173
- // Network
174
- const networks = container.NetworkSettings?.Networks || {};
175
- const network = Object.keys(networks)[0];
176
- if (!network) {
177
- return NextResponse.json({ error: 'Cannot determine network' }, { status: 500 });
178
- }
179
-
180
- // Port mappings
181
- const portBindings = (container.HostConfig?.PortBindings || {}) as Record<string, { HostPort: string }[]>;
182
- let portOpts = Object.entries(portBindings)
183
- .map(([containerPort, bindings]) => {
184
- const hostPort = bindings[0]?.HostPort;
185
- if (!hostPort) return '';
186
- const proto = containerPort.includes('/udp') ? '/udp' : '';
187
- return `-p ${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`;
188
- })
189
- .filter(Boolean)
190
- .join(' ');
191
-
192
- // If portRange is provided, replace the port mapping
193
- if (portRange) {
194
- const parts = portRange.trim().split('-');
195
- const start = parseInt(parts[0], 10);
196
- const end = parts[1] ? parseInt(parts[1], 10) : start;
197
- if (isNaN(start) || isNaN(end) || start < 1 || end > 65535 || end < start) {
198
- return NextResponse.json({ error: 'Invalid port range' }, { status: 400 });
199
- }
200
- if (end === start) {
201
- portOpts = `-p ${start}:3000`;
202
- } else {
203
- portOpts = `-p ${start}-${end}:3000-${3000 + (end - start)}`;
204
- }
205
- }
206
-
207
- const volumeName = `agent-${id}-data`;
208
-
209
- // Stop & remove old container (keep volume)
210
- try {
211
- execSync(`docker rm -f ${containerName}`, { timeout: 15000 });
212
- } catch (rmErr) {
213
- return NextResponse.json(
214
- { error: `Failed to remove old container: ${(rmErr as Error).message}` },
215
- { status: 500 },
216
- );
217
- }
218
-
219
- // Run new container with same volume
220
- const cmd = [
221
- `docker run -d`,
222
- `--name "${id}"`,
223
- `--network "${network}"`,
224
- `--restart unless-stopped`,
225
- `--add-host host.docker.internal:host-gateway`,
226
- `-v ${volumeName}:/root`,
227
- ...getMountFlags(),
228
- labelOpts,
229
- envOpts,
230
- portOpts,
231
- `"${image}"`,
232
- ].filter(Boolean).join(' \\\n ');
233
-
234
- execSync(cmd, { timeout: 60000, encoding: 'utf-8' });
235
-
236
- // Wait for startup to complete — /startupz, not /health
237
- const ready = await waitForGatewayReady(id);
238
- if (!ready) {
239
- return NextResponse.json(
240
- { error: 'Container started but its gateway did not finish starting within 60s.' },
241
- { status: 500 },
242
- );
243
- }
244
-
245
- // Apply runtime config
246
- try {
247
- applyRuntimeConfig(id);
248
- patchRev4aProvider(id);
249
- } catch (e) {
250
- // non-fatal, but a skipped patch must show up in the log
251
- console.error('[agent:update] runtime config patch failed:', (e as Error).message);
252
- }
253
-
254
- return NextResponse.json({
255
- success: true,
256
- displayName: displayName?.trim() || null,
257
- });
118
+ const rowId = await startAgentEdit(id, { displayName: body.displayName, portRange: body.portRange });
119
+ return NextResponse.json({ started: true, id: rowId }, { status: 202 });
258
120
  } catch (e: unknown) {
121
+ if (e instanceof EditRefusedError) {
122
+ const message = e.message;
123
+ // The shared port validator's messages (lib/agent-ports.ts) are validation errors:
124
+ // they answer 400, like the create route. Everything else is a busy refusal (409).
125
+ const status = /Invalid agent id|Display name must not be empty|No fields to update|Invalid format|Ports must be between|End port must be greater|reserved for Rev4a|already in use|Use a number/.test(message)
126
+ ? 400
127
+ : 409;
128
+ return NextResponse.json({ error: message }, { status });
129
+ }
259
130
  return NextResponse.json({ error: (e as Error).message }, { status: 500 });
260
131
  }
261
132
  }
262
133
 
134
+ /**
135
+ * GET /api/agents/[id]
136
+ *
137
+ * The running or last edit of an agent (`null` when it was never edited): what the edit
138
+ * panel polls to resume after a reload.
139
+ */
140
+ export async function GET(
141
+ request: Request,
142
+ { params }: { params: Promise<{ id: string }> },
143
+ ): Promise<NextResponse> {
144
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
145
+ const { id } = await params;
146
+ if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
147
+ return NextResponse.json({ edit: agentEditView(id) }, { headers: { 'Cache-Control': 'no-store' } });
148
+ }
149
+
150
+
263
151
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * POST /api/agents/[id]/update/rollback
3
+ *
4
+ * Roll back the agent's latest update (lib/agent-update.ts): its pre-update backup is
5
+ * restored and the container recreated on the version it ran before. Everything the
6
+ * agent did after that backup is lost. 202 with the update; the steps continue in the
7
+ * background, `GET /api/agents/[id]/update` follows them.
8
+ */
9
+ import { NextResponse, type NextRequest } from 'next/server';
10
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
11
+ import { isValidAgentId } from '@/lib/container';
12
+ import { UpdateRefusedError, agentUpdateView, startAgentRollback } from '@/lib/agent-update';
13
+
14
+ export const dynamic = 'force-dynamic';
15
+
16
+ export async function POST(
17
+ request: NextRequest,
18
+ { params }: { params: Promise<{ id: string }> },
19
+ ): Promise<NextResponse> {
20
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
21
+ const { id } = await params;
22
+ if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
23
+ try {
24
+ await startAgentRollback(id);
25
+ return NextResponse.json({ update: await agentUpdateView(id) }, { status: 202 });
26
+ } catch (e) {
27
+ const status = e instanceof UpdateRefusedError ? 409 : 500;
28
+ return NextResponse.json({ error: (e as Error).message }, { status });
29
+ }
30
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Update one agent to a newer supported OpenClaw version (lib/agent-update.ts).
3
+ *
4
+ * GET `{ currentVersion, candidate, update }`: the version the agent runs, the
5
+ * `{ fromVersion, toVersion }` an update would do (null when none is newer),
6
+ * and the latest update with its status and live backup progress.
7
+ * POST `{ version? }` — start an update, by default to the newest supported version
8
+ * downloaded. 202 with the new update; the steps continue in the background.
9
+ */
10
+ import { NextResponse, type NextRequest } from 'next/server';
11
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
12
+ import { isValidAgentId } from '@/lib/container';
13
+ import { isSupportedVersion } from '@/lib/agent-versions';
14
+ import { UpdateRefusedError, agentUpdateView, startAgentUpdate, updateCandidate } from '@/lib/agent-update';
15
+
16
+ export const dynamic = 'force-dynamic';
17
+
18
+ type Ctx = { params: Promise<{ id: string }> };
19
+
20
+ export async function GET(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
21
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
22
+ const { id } = await params;
23
+ if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
24
+ try {
25
+ const { currentVersion, candidate } = await updateCandidate(id);
26
+ return NextResponse.json(
27
+ { currentVersion, candidate, update: await agentUpdateView(id) },
28
+ { headers: { 'Cache-Control': 'no-store' } },
29
+ );
30
+ } catch (e) {
31
+ return NextResponse.json({ error: (e as Error).message }, { status: 503 });
32
+ }
33
+ }
34
+
35
+ export async function POST(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
36
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
37
+ const { id } = await params;
38
+ if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
39
+ const body = (await request.json().catch(() => null)) as { version?: unknown } | null;
40
+ if (body?.version !== undefined && body.version !== null && !isSupportedVersion(body.version)) {
41
+ return NextResponse.json({ error: 'version must be an OpenClaw version this Rev4a supports' }, { status: 400 });
42
+ }
43
+ try {
44
+ await startAgentUpdate(id, (body?.version as string | undefined) ?? undefined);
45
+ return NextResponse.json({ update: await agentUpdateView(id) }, { status: 202 });
46
+ } catch (e) {
47
+ const status = e instanceof UpdateRefusedError ? 409 : 500;
48
+ return NextResponse.json({ error: (e as Error).message }, { status });
49
+ }
50
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * GET /api/agents/activity-summary
3
+ *
4
+ * The long operation each agent is in the middle of, if any — the chip on the agent
5
+ * cards: `BACKUP nn%`, `RESTORING`, `RECREATING`, `UPDATING`.
6
+ *
7
+ * One Docker list call plus, per running cold backup, its status read. Idle agents are
8
+ * not listed at all, so an idle system costs one call.
9
+ */
10
+ import { NextResponse, type NextRequest } from 'next/server';
11
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
12
+ import { coldBackupStatus, listRunningColdBackups } from '@/lib/cold-backup';
13
+ import { activeRestoreAgentIds, latestRestore } from '@/lib/agent-restore-state';
14
+ import { activeRecreateAgentIds, latestRecreate } from '@/lib/agent-recreate-state';
15
+ import { activeUpdateAgentIds, latestUpdate } from '@/lib/agent-update-state';
16
+ import { activeEditAgentIds, latestEdit } from '@/lib/agent-edit-state';
17
+
18
+ export const dynamic = 'force-dynamic';
19
+
20
+ export type AgentActivityKind = 'backup' | 'restore' | 'recreate' | 'update' | 'edit';
21
+
22
+ interface AgentActivity {
23
+ agentId: string;
24
+ kind: AgentActivityKind;
25
+ file?: string | null;
26
+ version?: string | null;
27
+ percent?: number | null;
28
+ }
29
+
30
+ export async function GET(request: NextRequest): Promise<NextResponse> {
31
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
32
+
33
+ const byAgent = new Map<string, AgentActivity>();
34
+
35
+ // Update and recreate first: a running cold backup then overrides them, because while
36
+ // its helper archives the volume that is the phase actually in progress.
37
+ for (const agentId of activeUpdateAgentIds()) {
38
+ const row = latestUpdate(agentId);
39
+ if (row && (row.status === 'migrating' || row.status === 'verifying' || row.status === 'rolling_back')) {
40
+ byAgent.set(agentId, { agentId, kind: 'update', version: row.to_version });
41
+ }
42
+ }
43
+ for (const agentId of activeRecreateAgentIds()) {
44
+ const row = latestRecreate(agentId);
45
+ if (row && row.status === 'recreating') {
46
+ byAgent.set(agentId, { agentId, kind: 'recreate', version: row.image });
47
+ }
48
+ }
49
+ for (const agentId of activeRestoreAgentIds()) {
50
+ byAgent.set(agentId, { agentId, kind: 'restore', file: latestRestore(agentId)?.file ?? null });
51
+ }
52
+ for (const agentId of activeEditAgentIds()) {
53
+ const row = latestEdit(agentId);
54
+ if (row && row.status === 'rebuilding') {
55
+ byAgent.set(agentId, { agentId, kind: 'edit', file: row.display_name });
56
+ }
57
+ }
58
+
59
+ const backupIds = await listRunningColdBackups().catch(() => []);
60
+ await Promise.all(backupIds.map(async (agentId) => {
61
+ const job = await coldBackupStatus(agentId).catch(() => null);
62
+ if (!job || job.status !== 'running') return;
63
+ byAgent.set(agentId, { agentId, kind: 'backup', file: job.file, percent: job.percent });
64
+ }));
65
+
66
+ return NextResponse.json({ agents: [...byAgent.values()] }, { headers: { 'Cache-Control': 'no-store' } });
67
+ }
@@ -6,83 +6,19 @@ import * as path from 'path';
6
6
  import { patchRev4aProvider } from '../../gateway/sync';
7
7
  import { extractProviderAndModel } from '@/app/api/provider/upstream';
8
8
  import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
9
- import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
9
+ import { getMountFlags, applyRuntimeConfig, withControlUiPolicy } from '@/lib/agent-setup';
10
+ import { listLocalAgentImages, newestLocalSupportedVersion } from '@/lib/agent-images';
11
+ import { localImageRef } from '@/lib/agent-versions';
10
12
  import { isModelOffered } from '@/lib/model-catalogue';
13
+ import { findAvailablePortBlock, portMappingArgs, validatePortInput, DEFAULT_BLOCK_SIZE } from '@/lib/agent-ports';
14
+ import { getUsedHostPorts } from '@/lib/agent-ports-server';
11
15
  import { requireAuthJWT } from '@/lib/rev4a-auth';
12
16
  import { waitForGatewayReady } from '@/lib/agent-readiness';
13
17
 
14
18
 
15
19
 
16
- /**
17
- * Find an available host port block starting from 3700.
18
- */
19
- const REV4A_PORT = 3740;
20
- const DEFAULT_BLOCK_SIZE = 10;
21
-
22
- function getUsedHostPorts(): Set<number> {
23
- try {
24
- // Parse host ports in JS rather than piping through grep. The previous
25
- // form used `grep -oP '\d+(?=->)'` inside a template literal, where \d
26
- // loses its backslash — the shell received `d+(?=->)`, which never
27
- // matches Docker's `0.0.0.0:3700->3000/tcp`, so this always returned an
28
- // empty set and every agent was offered port 3700. `grep -P` is also
29
- // GNU-only and absent on macOS. This mirrors the client-side parser in
30
- // app/agents/create/page.tsx.
31
- const raw = execSync(
32
- `docker ps --format '{{.Ports}}'`,
33
- { encoding: 'utf-8', timeout: 3000 },
34
- );
35
- const used = new Set<number>();
36
- for (const [, port] of raw.matchAll(/(\d+)->/g)) {
37
- const n = Number(port);
38
- if (Number.isInteger(n)) used.add(n);
39
- }
40
- return used;
41
- } catch {
42
- return new Set();
43
- }
44
- }
45
-
46
- function findAvailablePortBlock(blockSize = DEFAULT_BLOCK_SIZE): number {
47
- const usedPorts = getUsedHostPorts();
48
- for (let base = 3700; base + blockSize - 1 <= 3799; base += DEFAULT_BLOCK_SIZE) {
49
- if (base <= REV4A_PORT && REV4A_PORT <= base + blockSize - 1) continue;
50
- const block = Array.from({ length: blockSize }, (_, i) => base + i);
51
- if (block.every((p) => !usedPorts.has(p))) return base;
52
- }
53
- let base = 3800;
54
- while (true) {
55
- const block = Array.from({ length: blockSize }, (_, i) => base + i);
56
- if (block.every((p) => !usedPorts.has(p))) return base;
57
- base += DEFAULT_BLOCK_SIZE;
58
- }
59
- }
60
-
61
- function validatePortInput(input: string): { valid: true; isBlock: boolean; start: number; end: number } | { valid: false; error: string } {
62
- const trimmed = input.trim();
63
- if (trimmed.includes('-')) {
64
- const m = trimmed.match(/^(\d{1,5})-(\d{1,5})$/);
65
- if (!m) return { valid: false, error: 'Invalid format. Use e.g. 3700-3709' };
66
- const start = parseInt(m[1], 10);
67
- const end = parseInt(m[2], 10);
68
- if (start < 1 || end > 65535) return { valid: false, error: 'Ports must be between 1 and 65535' };
69
- if (end <= start) return { valid: false, error: 'End port must be greater than start port' };
70
- if (start <= REV4A_PORT && REV4A_PORT <= end) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
71
- const usedPorts = getUsedHostPorts();
72
- const conflicts: number[] = [];
73
- for (let p = start; p <= end; p++) { if (usedPorts.has(p)) conflicts.push(p); }
74
- if (conflicts.length > 0) return { valid: false, error: `Ports already in use: ${conflicts.join(', ')}` };
75
- return { valid: true, isBlock: true, start, end };
76
- }
77
- const m = trimmed.match(/^(\d{1,5})$/);
78
- if (!m) return { valid: false, error: 'Use a number (e.g. 3700) or a range (e.g. 3700-3709)' };
79
- const port = parseInt(m[1], 10);
80
- if (port < 1 || port > 65535) return { valid: false, error: 'Port must be between 1 and 65535' };
81
- if (port === REV4A_PORT) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
82
- const usedPorts = getUsedHostPorts();
83
- if (usedPorts.has(port)) return { valid: false, error: `Port ${port} is already in use` };
84
- return { valid: true, isBlock: false, start: port, end: port };
85
- }
20
+ // Port block discovery and validation are shared with the edit (rename/ports) flow, so
21
+ // both offer the same ranges and the same error messages (lib/agent-ports.ts).
86
22
 
87
23
  export const dynamic = 'force-dynamic';
88
24
  export const maxDuration = 180; // docker run (60s) + startup wait (60s) + seeding (60s)
@@ -205,7 +141,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
205
141
  let portEnd: number = 0;
206
142
 
207
143
  if (portInput) {
208
- const v = validatePortInput(portInput);
144
+ const v = validatePortInput(portInput, getUsedHostPorts());
209
145
  if (!v.valid) {
210
146
  return NextResponse.json(
211
147
  { success: false, error: v.error },
@@ -216,7 +152,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
216
152
  portStart = v.start;
217
153
  portEnd = v.end;
218
154
  } else {
219
- portStart = findAvailablePortBlock();
155
+ portStart = findAvailablePortBlock(getUsedHostPorts());
220
156
  portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
221
157
  isBlock = true;
222
158
  }
@@ -225,7 +161,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
225
161
  const mountFlags = getMountFlags();
226
162
 
227
163
  // Build docker run command
228
- const image = 'openclaw-agent-base:latest';
164
+ // The newest supported OpenClaw version downloaded here (lib/agent-images.ts).
165
+ let newestLocal: string | null = null;
166
+ try {
167
+ newestLocal = newestLocalSupportedVersion(await listLocalAgentImages());
168
+ } catch {
169
+ // Docker listed nothing usable: handled below as no image.
170
+ }
171
+ if (!newestLocal) {
172
+ return NextResponse.json(
173
+ { success: false, error: 'No supported agent image is downloaded. Download it from the Agents page first.' },
174
+ { status: 409 },
175
+ );
176
+ }
177
+ const image = localImageRef(newestLocal);
229
178
  const network = getNetwork();
230
179
 
231
180
  // Read agents token for new containers
@@ -241,9 +190,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
241
190
  // not ready yet; without it `docker run --network` fails. Idempotent.
242
191
  ensureNetworkExists(network);
243
192
 
244
- // Every agent gets a host port range for direct access. The URL is composed
245
- // by the browser from `port` + `controlToken` in the response — only the
246
- // browser knows a hostname that actually reaches this machine.
193
+ // Every agent gets a host port range for direct access. The Control UI link
194
+ // is built per click by /api/agents/[id]/open-control-ui, from the host the
195
+ // dashboard is reached on.
247
196
  //
248
197
  // There used to be an AGENT_HOSTNAME env var here, holding `<detected-ip>:<port>`.
249
198
  // Nothing ever read it: verified across this repo and across the whole
@@ -263,9 +212,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
263
212
 
264
213
  const labelOpts = labels.map((l) => `-l ${shellQuote(l)}`).join(' ');
265
214
  const envOpts = envVars.map((e) => `-e ${shellQuote(e)}`).join(' ');
266
- const portOpt = isBlock
267
- ? `-p ${portStart}-${portEnd}:3000-${3000 + (portEnd - portStart)}`
268
- : `-p ${portStart}:3000`;
215
+ const portOpt = portMappingArgs(portStart, portEnd).join(' ');
269
216
 
270
217
  // Template files that will be copied into the container workspace.
271
218
  // Files that don't exist in the template dir are silently skipped —
@@ -370,14 +317,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
370
317
 
371
318
  // Merge all required sections in one pass (single write, single restart)
372
319
 
373
- // controlUi — allow browser Control UI access.
374
- // deviceAuth is disabled because agent containers are only reachable
375
- // on the internal Docker network; the gateway token is the sole auth barrier.
320
+ // controlUi — keep what the image and OpenClaw wrote, and apply the browser-origin
321
+ // policy so the Control UI opens on whatever host the agent is reached on.
322
+ // Recreate re-applies the same policy through applyRuntimeConfig.
376
323
  const gatewaySection = (config.gateway || {}) as Record<string, unknown>;
377
- gatewaySection.controlUi = {
378
- allowedOrigins: ['*'],
379
- dangerouslyDisableDeviceAuth: true,
380
- };
324
+ gatewaySection.controlUi = withControlUiPolicy(gatewaySection.controlUi);
381
325
  config.gateway = gatewaySection;
382
326
 
383
327
  // Model reference (primary + fallbacks)
@@ -426,7 +370,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
426
370
  // create-specific config so they're always present regardless of
427
371
  // what was in the persistent volume.
428
372
  try {
429
- applyRuntimeConfig(containerName);
373
+ await applyRuntimeConfig(containerName);
430
374
 
431
375
  // Also guarantee models.providers.rev4a (host-dependent, not in base image).
432
376
  // This patch needs no restart; the earlier one follows a full-file rewrite
@@ -0,0 +1,37 @@
1
+ /**
2
+ * GET /api/agents/devices-summary
3
+ *
4
+ * Browsers waiting for approval, per running agent — the "browser waiting" badge on
5
+ * the agent cards. Agents whose config disables device auth (2026.7.x) are answered
6
+ * from the config alone, without the CLI. One agent that cannot be read reports
7
+ * `error: true` and does not blank the others.
8
+ */
9
+ import { NextResponse, type NextRequest } from 'next/server';
10
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
11
+ import { mapWithConcurrency } from '@/lib/docker-exec';
12
+ import { listBrowserAccess, listRunningAgentContainers } from '@/lib/agent-devices';
13
+
14
+ export const dynamic = 'force-dynamic';
15
+
16
+ const CONCURRENCY = 3;
17
+
18
+ export async function GET(request: NextRequest): Promise<NextResponse> {
19
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
20
+
21
+ let containers: { agentId: string; name: string }[];
22
+ try {
23
+ containers = await listRunningAgentContainers();
24
+ } catch (e) {
25
+ return NextResponse.json({ agents: [], error: `Docker is unreachable: ${(e as Error).message}` }, { status: 503 });
26
+ }
27
+
28
+ const agents = await mapWithConcurrency(containers, CONCURRENCY, async ({ agentId, name }) => {
29
+ try {
30
+ const access = await listBrowserAccess(name);
31
+ return { agentId, requiresApproval: access.requiresApproval, pending: access.pending.length };
32
+ } catch {
33
+ return { agentId, requiresApproval: null, pending: 0, error: true };
34
+ }
35
+ });
36
+ return NextResponse.json({ agents });
37
+ }
@@ -2,15 +2,16 @@ import { type NextRequest } from "next/server";
2
2
  /**
3
3
  * POST /api/agents/download-image
4
4
  *
5
- * Triggers a fresh download (pull) of the openclaw-agent-base image.
6
- * Returns 202 Accepted immediately — the operation runs in the background.
7
- * Poll GET /api/agents/download-progress and
8
- * GET /api/agents/image-status for completion.
5
+ * Downloads one OpenClaw version of the agent base image in the background.
6
+ * Body (optional): `{ "version": "2026.9.3" }` — a supported version; without it, the
7
+ * newest supported version the registry publishes. Returns 202 with the version;
8
+ * poll GET /api/agents/image-status for completion.
9
9
  *
10
- * Requires auth. Returns 409 if a download is already running.
10
+ * Requires auth. 400 for an unsupported version, 409 if a download is already running.
11
11
  */
12
12
  import { NextResponse } from 'next/server';
13
- import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
13
+ import { defaultDownloadVersion, downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
14
+ import { isSupportedVersion } from '@/lib/agent-versions';
14
15
  import { requireAuthJWT } from '@/lib/rev4a-auth';
15
16
 
16
17
  export const dynamic = 'force-dynamic';
@@ -25,10 +26,16 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
25
26
  );
26
27
  }
27
28
 
29
+ const body = (await request.json().catch(() => null)) as { version?: unknown } | null;
30
+ if (body?.version !== undefined && !isSupportedVersion(body.version)) {
31
+ return NextResponse.json({ error: 'version must be an OpenClaw version this Rev4a supports' }, { status: 400 });
32
+ }
33
+ const version = (body?.version as string | undefined) ?? (await defaultDownloadVersion());
34
+
28
35
  // Start download in background — fire and forget
29
- downloadAgentImage({ force: true }).catch(() => {
30
- // Errors are logged to the download log file
36
+ downloadAgentImage({ version }).catch((e: unknown) => {
37
+ console.error(`[download-image] OpenClaw ${version}:`, (e as Error).message);
31
38
  });
32
39
 
33
- return NextResponse.json({ started: true }, { status: 202 });
40
+ return NextResponse.json({ started: true, version }, { status: 202 });
34
41
  }