@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
@@ -1,118 +1,38 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { execSync } from 'child_process';
2
+ import { execSync, execFileSync } from 'child_process';
3
3
  import * as crypto from 'crypto';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
- import { buildRev4aProviderConfig } from '../../gateway/sync';
6
+ import { patchRev4aProvider } from '../../gateway/sync';
7
+ import { extractProviderAndModel } from '@/app/api/provider/upstream';
7
8
  import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
8
- import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
9
- import { loadModelsConfig } from '@/lib/model-catalogue';
9
+ import { getMountFlags, applyRuntimeConfig, withControlUiPolicy } from '@/lib/agent-setup';
10
+ import { listLocalAgentImages, newestLocalSupportedVersion } from '@/lib/agent-images';
11
+ import { localImageRef } from '@/lib/agent-versions';
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';
10
15
  import { requireAuthJWT } from '@/lib/rev4a-auth';
11
-
12
- /**
13
- * Wait for a Docker container gateway to be ready.
14
- * Checks that the gateway is responding on port 3000 inside the container.
15
- * Polls every 2s, gives up after timeoutMs.
16
- * Returns true if the gateway is reachable, false on timeout.
17
- */
18
- function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
19
- const start = Date.now();
20
- while (Date.now() - start < timeoutMs) {
21
- try {
22
- execSync(
23
- `docker exec ${name} sh -c 'curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1 || wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1'`,
24
- { timeout: 5000 },
25
- );
26
- return true;
27
- } catch {
28
- // gateway not ready yet
29
- }
30
- // Sleep 2s
31
- execSync('sleep 2', { timeout: 3000 });
32
- }
33
- return false;
34
- }
16
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
35
17
 
36
18
 
37
19
 
38
- /**
39
- * Find an available host port block starting from 3700.
40
- */
41
- const REV4A_PORT = 3740;
42
- const DEFAULT_BLOCK_SIZE = 10;
43
-
44
- function getUsedHostPorts(): Set<number> {
45
- try {
46
- // Parse host ports in JS rather than piping through grep. The previous
47
- // form used `grep -oP '\d+(?=->)'` inside a template literal, where \d
48
- // loses its backslash — the shell received `d+(?=->)`, which never
49
- // matches Docker's `0.0.0.0:3700->3000/tcp`, so this always returned an
50
- // empty set and every agent was offered port 3700. `grep -P` is also
51
- // GNU-only and absent on macOS. This mirrors the client-side parser in
52
- // app/agents/create/page.tsx.
53
- const raw = execSync(
54
- `docker ps --format '{{.Ports}}'`,
55
- { encoding: 'utf-8', timeout: 3000 },
56
- );
57
- const used = new Set<number>();
58
- for (const [, port] of raw.matchAll(/(\d+)->/g)) {
59
- const n = Number(port);
60
- if (Number.isInteger(n)) used.add(n);
61
- }
62
- return used;
63
- } catch {
64
- return new Set();
65
- }
66
- }
67
-
68
- function findAvailablePortBlock(blockSize = DEFAULT_BLOCK_SIZE): number {
69
- const usedPorts = getUsedHostPorts();
70
- for (let base = 3700; base + blockSize - 1 <= 3799; base += DEFAULT_BLOCK_SIZE) {
71
- if (base <= REV4A_PORT && REV4A_PORT <= base + blockSize - 1) continue;
72
- const block = Array.from({ length: blockSize }, (_, i) => base + i);
73
- if (block.every((p) => !usedPorts.has(p))) return base;
74
- }
75
- let base = 3800;
76
- while (true) {
77
- const block = Array.from({ length: blockSize }, (_, i) => base + i);
78
- if (block.every((p) => !usedPorts.has(p))) return base;
79
- base += DEFAULT_BLOCK_SIZE;
80
- }
81
- }
82
-
83
- function validatePortInput(input: string): { valid: true; isBlock: boolean; start: number; end: number } | { valid: false; error: string } {
84
- const trimmed = input.trim();
85
- if (trimmed.includes('-')) {
86
- const m = trimmed.match(/^(\d{1,5})-(\d{1,5})$/);
87
- if (!m) return { valid: false, error: 'Invalid format. Use e.g. 3700-3709' };
88
- const start = parseInt(m[1], 10);
89
- const end = parseInt(m[2], 10);
90
- if (start < 1 || end > 65535) return { valid: false, error: 'Ports must be between 1 and 65535' };
91
- if (end <= start) return { valid: false, error: 'End port must be greater than start port' };
92
- if (start <= REV4A_PORT && REV4A_PORT <= end) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
93
- const usedPorts = getUsedHostPorts();
94
- const conflicts: number[] = [];
95
- for (let p = start; p <= end; p++) { if (usedPorts.has(p)) conflicts.push(p); }
96
- if (conflicts.length > 0) return { valid: false, error: `Ports already in use: ${conflicts.join(', ')}` };
97
- return { valid: true, isBlock: true, start, end };
98
- }
99
- const m = trimmed.match(/^(\d{1,5})$/);
100
- if (!m) return { valid: false, error: 'Use a number (e.g. 3700) or a range (e.g. 3700-3709)' };
101
- const port = parseInt(m[1], 10);
102
- if (port < 1 || port > 65535) return { valid: false, error: 'Port must be between 1 and 65535' };
103
- if (port === REV4A_PORT) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
104
- const usedPorts = getUsedHostPorts();
105
- if (usedPorts.has(port)) return { valid: false, error: `Port ${port} is already in use` };
106
- return { valid: true, isBlock: false, start: port, end: port };
107
- }
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).
108
22
 
109
23
  export const dynamic = 'force-dynamic';
110
- export const maxDuration = 180; // docker run (60s) + health check (60s) + seeding (60s)
24
+ export const maxDuration = 180; // docker run (60s) + startup wait (60s) + seeding (60s)
111
25
 
112
26
  const TEMPLATES_DIR = templatesDir(process.cwd());
113
27
  const AGENTS_TOKEN_PATH = AGENTS_TOKEN_FILE;
114
28
  const DEFAULT_NETWORK = 'rev4a-network';
115
29
 
30
+ /**
31
+ * Used when the request names no model. The full id: the agent cards compare the
32
+ * primary against catalogue ids, and the two-segment alias matches none of them.
33
+ */
34
+ const DEFAULT_MODEL = 'rev4a/deepseek/deepseek-flash';
35
+
116
36
  function isDockerCompatibleName(name: string): boolean {
117
37
  return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
118
38
  }
@@ -191,9 +111,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
191
111
  }
192
112
 
193
113
  // Validate model (if provided) exists in models.config.json
194
- if (model && !isValidModel(model)) {
114
+ // A request without a model used to create an agent with no primary at all —
115
+ // the config write below was skipped — while the event log recorded a default
116
+ // the agent never had. The default is now applied for real, and validated like
117
+ // any other model, so it cannot produce an agent the proxy refuses.
118
+ const effectiveModel: string = typeof model === 'string' && model ? model : DEFAULT_MODEL;
119
+ if (!isValidModel(effectiveModel)) {
195
120
  return NextResponse.json(
196
- { success: false, error: `Model '${model}' not found in models.config.json` },
121
+ { success: false, error: `Model '${effectiveModel}' is not offered by this deployment: it is not in the catalogue, is disabled, or its provider has no API key.` },
197
122
  { status: 400 },
198
123
  );
199
124
  }
@@ -216,7 +141,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
216
141
  let portEnd: number = 0;
217
142
 
218
143
  if (portInput) {
219
- const v = validatePortInput(portInput);
144
+ const v = validatePortInput(portInput, getUsedHostPorts());
220
145
  if (!v.valid) {
221
146
  return NextResponse.json(
222
147
  { success: false, error: v.error },
@@ -227,7 +152,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
227
152
  portStart = v.start;
228
153
  portEnd = v.end;
229
154
  } else {
230
- portStart = findAvailablePortBlock();
155
+ portStart = findAvailablePortBlock(getUsedHostPorts());
231
156
  portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
232
157
  isBlock = true;
233
158
  }
@@ -236,7 +161,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
236
161
  const mountFlags = getMountFlags();
237
162
 
238
163
  // Build docker run command
239
- 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);
240
178
  const network = getNetwork();
241
179
 
242
180
  // Read agents token for new containers
@@ -248,9 +186,13 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
248
186
  );
249
187
  }
250
188
 
251
- // Every agent gets a host port range for direct access. The URL is composed
252
- // by the browser from `port` + `controlToken` in the response — only the
253
- // browser knows a hostname that actually reaches this machine.
189
+ // `rev4a serve` creates this network at start-up, but skips it when Docker was
190
+ // not ready yet; without it `docker run --network` fails. Idempotent.
191
+ ensureNetworkExists(network);
192
+
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.
254
196
  //
255
197
  // There used to be an AGENT_HOSTNAME env var here, holding `<detected-ip>:<port>`.
256
198
  // Nothing ever read it: verified across this repo and across the whole
@@ -270,9 +212,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
270
212
 
271
213
  const labelOpts = labels.map((l) => `-l ${shellQuote(l)}`).join(' ');
272
214
  const envOpts = envVars.map((e) => `-e ${shellQuote(e)}`).join(' ');
273
- const portOpt = isBlock
274
- ? `-p ${portStart}-${portEnd}:3000-${3000 + (portEnd - portStart)}`
275
- : `-p ${portStart}:3000`;
215
+ const portOpt = portMappingArgs(portStart, portEnd).join(' ');
276
216
 
277
217
  // Template files that will be copied into the container workspace.
278
218
  // Files that don't exist in the template dir are silently skipped —
@@ -310,7 +250,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
310
250
  port: portStart,
311
251
  portEnd,
312
252
  isBlock,
313
- model: model || 'rev4a/deepseek-v4-flash',
253
+ model: effectiveModel,
314
254
  fallbacks,
315
255
  containerId,
316
256
  containerName,
@@ -321,14 +261,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
321
261
  // DB logging is optional, ignore failures
322
262
  }
323
263
 
324
- // Wait for container to be ready (HTTP health check, up to 60s).
325
- // If it doesn't come up, remove the container and return an error —
326
- // a zombie agent with no config is worse than an explicit failure.
327
- const ready = waitForContainerReady(containerName);
264
+ // Wait for startup to complete (/startupz, up to 60s). Not /health, which
265
+ // answers as soon as the HTTP server listens. If it does not come up, remove
266
+ // the container: a zombie agent with no config is worse than an explicit failure.
267
+ const ready = await waitForGatewayReady(containerName);
328
268
  if (!ready) {
329
269
  try { execSync(`docker rm -f ${containerName}`, { timeout: 10000 }); } catch { /* best-effort */ }
330
270
  return NextResponse.json(
331
- { success: false, error: `Container started but gateway did not become healthy within 60s. Container removed — try again.` },
271
+ { success: false, error: `Container started but its gateway did not finish starting within 60s. Container removed — try again.` },
332
272
  { status: 500 },
333
273
  );
334
274
  }
@@ -377,22 +317,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
377
317
 
378
318
  // Merge all required sections in one pass (single write, single restart)
379
319
 
380
- // controlUi — allow browser Control UI access.
381
- // deviceAuth is disabled because agent containers are only reachable
382
- // 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.
383
323
  const gatewaySection = (config.gateway || {}) as Record<string, unknown>;
384
- gatewaySection.controlUi = {
385
- allowedOrigins: ['*'],
386
- dangerouslyDisableDeviceAuth: true,
387
- };
324
+ gatewaySection.controlUi = withControlUiPolicy(gatewaySection.controlUi);
388
325
  config.gateway = gatewaySection;
389
326
 
390
327
  // Model reference (primary + fallbacks)
391
- if (model) {
392
- const modelRef = model.startsWith('rev4a/') ? model : `rev4a/${model}`;
393
- const fallbackRefs = (fallbacks || []).map((fb: string) =>
394
- fb.startsWith('rev4a/') ? fb : `rev4a/${fb}`,
395
- );
328
+ {
329
+ // Always the canonical `rev4a/<provider>/<model>`: the Agents chips and the
330
+ // Model panel look the primary up by catalogue id, so the alias form
331
+ // (`rev4a/deepseek-flash`) or a bare name would show as not in the catalogue.
332
+ const modelRef = toRev4aRef(effectiveModel);
333
+ const fallbackRefs = (fallbacks || []).map((fb: string) => toRev4aRef(fb));
396
334
  const agents = (config.agents || {}) as Record<string, unknown>;
397
335
  const defaults = (agents.defaults || {}) as Record<string, unknown>;
398
336
  defaults.model = {
@@ -432,21 +370,12 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
432
370
  // create-specific config so they're always present regardless of
433
371
  // what was in the persistent volume.
434
372
  try {
435
- applyRuntimeConfig(containerName);
436
-
437
- // Also guarantee models.providers.rev4a (host-dependent, not in base image)
438
- const providerConfig = buildRev4aProviderConfig();
439
- const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
440
- execSync(
441
- `docker exec -i ${containerName} sh -c 'openclaw config patch --stdin'`,
442
- { timeout: 15000, stdio: 'pipe', input: providerJson },
443
- );
373
+ await applyRuntimeConfig(containerName);
444
374
 
445
- // No gateway restart: `config patch` reports "Change will apply without
446
- // restarting the gateway" for a models-only patch, and the Gateway watches
447
- // openclaw.json with gateway.reload defaulting to hybrid. The earlier
448
- // restart in this route stays — that one follows a full-file rewrite that
449
- // includes gateway.*, which is the one section that genuinely needs it.
375
+ // Also guarantee models.providers.rev4a (host-dependent, not in base image).
376
+ // This patch needs no restart; the earlier one follows a full-file rewrite
377
+ // that includes gateway.*, the one section that genuinely needs it.
378
+ patchRev4aProvider(containerName);
450
379
  } catch (e: unknown) {
451
380
  // runtime config patch is best-effort; log but don't fail the create
452
381
  console.error('[agent:create] runtime config patch failed:', (e as Error).message || String(e));
@@ -474,6 +403,12 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
474
403
  }
475
404
  }
476
405
 
406
+ /** `rev4a/<provider>/<model>`, whatever form the request used. */
407
+ function toRev4aRef(modelId: string): string {
408
+ const { provider, model } = extractProviderAndModel({ model: modelId });
409
+ return `rev4a/${provider}/${model}`;
410
+ }
411
+
477
412
  function getAvailableTemplates(): string[] {
478
413
  try {
479
414
  return fs.readdirSync(TEMPLATES_DIR).filter((f) => {
@@ -485,12 +420,23 @@ function getAvailableTemplates(): string[] {
485
420
  }
486
421
  }
487
422
 
423
+ /**
424
+ * The model must be one this deployment offers, in any form the proxy accepts.
425
+ * Checking the raw catalogue let the API create an agent on a disabled model, or
426
+ * one whose provider has no key, which the proxy then refuses on its first turn.
427
+ */
488
428
  function isValidModel(modelId: string): boolean {
429
+ const { provider, model } = extractProviderAndModel({ model: modelId });
430
+ return isModelOffered(provider, model);
431
+ }
432
+
433
+ function ensureNetworkExists(name: string): void {
489
434
  try {
490
- const config = loadModelsConfig();
491
- return config.some((m) => m.id === modelId);
435
+ execFileSync('docker', ['network', 'inspect', name], { stdio: 'ignore', timeout: 5000 });
492
436
  } catch {
493
- return true; // skip on errors
437
+ try {
438
+ execFileSync('docker', ['network', 'create', name], { stdio: 'ignore', timeout: 10000 });
439
+ } catch { /* let docker run report it */ }
494
440
  }
495
441
  }
496
442
 
@@ -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
  }
@@ -1,135 +1,55 @@
1
- import { type NextRequest } from "next/server";
2
1
  /**
3
2
  * GET /api/agents/image-status
4
3
  *
5
- * Reports whether the openclaw-agent-base image is present, whether a download
6
- * is in flight, and whether a newer image exists in the registry.
4
+ * Which OpenClaw versions of the agent base image are downloaded, whether the
5
+ * registry publishes a newer supported version, and whether a download is running.
7
6
  *
8
- * The four fields cost wildly different amounts, which is what makes this route
9
- * worth structuring carefully. ImageDownloadBanner polls it every 2s to animate
10
- * download progress, so anything slow in here is paid over and over:
11
- *
12
- * downloading in-memory flag free
13
- * error local free
14
- * exists one `docker image inspect` ~0.5s
15
- * needsUpdate `docker manifest inspect` over the net ~3.4s
16
- *
17
- * The route used to compute all of them, synchronously, on every poll — a 2s
18
- * interval driving a ~5s blocking call, so the whole server sat frozen for as
19
- * long as the agents page stayed open. It also ran `docker version` three times
20
- * per request (once inside each helper) just to ask whether Docker was up, at
21
- * ~2s a call, and inspected the same image three times over.
22
- *
23
- * Now: Docker liveness comes from the socket check in lib/docker-socket (a
24
- * cached filesystem stat), the image is inspected once, everything is async, and
25
- * the registry lookup — the only genuinely expensive part — is cached. The base
26
- * image changes when a release is published, not every two seconds.
7
+ * ImageDownloadBanner polls this every 2 s. The local part is two Docker API calls;
8
+ * the registry lookup is cached for ten minutes and shared by concurrent requests
9
+ * (lib/agent-images.ts), so polling does not hit the network.
27
10
  */
28
- import { NextResponse } from 'next/server';
29
- import { execFile } from 'child_process';
30
- import { promisify } from 'util';
31
- import { getIsDownloading } from '@/lib/buildAgentImage';
11
+ import { NextResponse, type NextRequest } from 'next/server';
12
+ import { getDownloadingVersion, getIsDownloading } from '@/lib/buildAgentImage';
32
13
  import { requireAuthJWT } from '@/lib/rev4a-auth';
33
14
  import { dockerAvailable } from '@/lib/docker-socket';
34
-
35
- const execFileAsync = promisify(execFile);
15
+ import { listLocalAgentImages, localSupportedVersions, remoteSupportedVersions, type LocalAgentImage } from '@/lib/agent-images';
16
+ import { compareVersions } from '@/lib/agent-versions';
36
17
 
37
18
  export const dynamic = 'force-dynamic';
38
19
 
39
- const REGISTRY_IMAGE = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
40
- const LOCAL_IMAGE = 'openclaw-agent-base:latest';
41
- const REGISTRY_PREFIX = 'ghcr.io/flame0510/rev4a/openclaw-agent-base@';
42
-
43
- /**
44
- * How long a registry lookup is reused. Long enough that a 2s poll doesn't hit
45
- * the network, short enough that a freshly published base image surfaces on its
46
- * own. The cost of being stale here is a banner appearing a few minutes late.
47
- */
48
- const REMOTE_TTL_MS = 10 * 60 * 1000;
49
-
50
- let remoteCache: { digest: string | null; at: number } | null = null;
51
- /** Shared across concurrent requests so a burst of polls triggers one lookup. */
52
- let remoteInFlight: Promise<string | null> | null = null;
53
-
54
- /** The local image's ID and its registry digest, from a single inspect. */
55
- async function inspectLocalImage(): Promise<{ id: string; registryDigest: string | null } | null> {
56
- try {
57
- const { stdout } = await execFileAsync(
58
- 'docker',
59
- ['image', 'inspect', LOCAL_IMAGE, '--format', '{{json .}}'],
60
- { encoding: 'utf-8', timeout: 5000 },
61
- );
62
- const info = JSON.parse(stdout) as { Id?: string; RepoDigests?: string[] };
63
- const fromRegistry = (info.RepoDigests ?? []).find((d) => d.startsWith(REGISTRY_PREFIX));
64
- return {
65
- id: info.Id ?? '',
66
- registryDigest: fromRegistry ? fromRegistry.split('@')[1] ?? null : null,
67
- };
68
- } catch {
69
- return null;
70
- }
71
- }
72
-
73
- /** Manifest digest of the published image, cached for REMOTE_TTL_MS. */
74
- async function getRemoteDigest(): Promise<string | null> {
75
- if (remoteCache && Date.now() - remoteCache.at < REMOTE_TTL_MS) return remoteCache.digest;
76
- if (remoteInFlight) return remoteInFlight;
77
-
78
- remoteInFlight = (async () => {
79
- try {
80
- const { stdout } = await execFileAsync(
81
- 'docker',
82
- ['manifest', 'inspect', REGISTRY_IMAGE, '--verbose'],
83
- { encoding: 'utf-8', timeout: 15000 },
84
- );
85
- const manifest = JSON.parse(stdout) as { Descriptor?: { digest?: string } };
86
- // The descriptor digest is what RepoDigests can be compared against;
87
- // config.digest is a different hash entirely.
88
- return manifest?.Descriptor?.digest ?? null;
89
- } catch {
90
- return null;
91
- }
92
- })();
93
-
94
- try {
95
- const digest = await remoteInFlight;
96
- remoteCache = { digest, at: Date.now() };
97
- return digest;
98
- } finally {
99
- remoteInFlight = null;
100
- }
101
- }
102
-
103
20
  export async function GET(request: NextRequest): Promise<NextResponse> {
104
21
  const denied = await requireAuthJWT(request);
105
22
  if (denied) return denied;
106
23
 
107
- const downloading = getIsDownloading();
24
+ const base = { downloading: getIsDownloading(), downloadingVersion: getDownloadingVersion() };
108
25
 
109
26
  if (!dockerAvailable()) {
110
- return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
27
+ return NextResponse.json({
28
+ ...base, exists: false, needsUpdate: true, localVersions: [], newestLocal: null, available: null, registryReachable: false,
29
+ });
111
30
  }
112
31
 
113
- const local = await inspectLocalImage();
114
- if (!local) {
115
- return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
32
+ let images: LocalAgentImage[] = [];
33
+ try {
34
+ images = await listLocalAgentImages();
35
+ } catch {
36
+ // Docker answered nothing usable: report no local image.
116
37
  }
38
+ const localVersions = localSupportedVersions(images);
39
+ const newestLocal = localVersions[0] ?? null;
117
40
 
118
- // A cached registry digest answers immediately; a cold one costs a network
119
- // round trip, but only once per TTL rather than once per poll.
120
- const remoteDigest = await getRemoteDigest();
121
-
122
- let needsUpdate = false;
123
- if (remoteDigest) {
124
- // No registry digest locally means the image was built here and never
125
- // pulled, so it can't be compared — treat it as updatable.
126
- needsUpdate = local.registryDigest ? local.registryDigest !== remoteDigest : true;
127
- }
41
+ const remote = await remoteSupportedVersions();
42
+ const available = remote?.find(
43
+ (v) => !localVersions.includes(v) && (!newestLocal || compareVersions(v, newestLocal) > 0),
44
+ ) ?? null;
128
45
 
129
46
  return NextResponse.json({
130
- exists: true,
131
- needsUpdate,
132
- downloading,
133
- localId: local.id || null,
47
+ ...base,
48
+ exists: newestLocal !== null,
49
+ needsUpdate: newestLocal === null || available !== null,
50
+ localVersions,
51
+ newestLocal,
52
+ available,
53
+ registryReachable: remote !== null,
134
54
  });
135
55
  }