@flame0510/project-aether 1.3.0 → 1.4.1

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 (78) 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/agents/create/page.tsx +14 -28
  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]/open-control-ui/route.ts +58 -0
  21. package/app/api/agents/[id]/recreate/route.ts +33 -163
  22. package/app/api/agents/[id]/restart/route.ts +5 -0
  23. package/app/api/agents/[id]/restore/route.ts +40 -70
  24. package/app/api/agents/[id]/route.ts +38 -150
  25. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  26. package/app/api/agents/[id]/update/route.ts +50 -0
  27. package/app/api/agents/activity-summary/route.ts +67 -0
  28. package/app/api/agents/create/route.ts +38 -92
  29. package/app/api/agents/devices-summary/route.ts +37 -0
  30. package/app/api/agents/download-image/route.ts +16 -9
  31. package/app/api/agents/image-status/route.ts +31 -111
  32. package/app/api/agents/route.ts +25 -49
  33. package/app/api/agents/token/route.ts +33 -10
  34. package/app/api/assistant/route.ts +2 -2
  35. package/app/api/gateway/agent/route.ts +14 -0
  36. package/app/api/gateway/provider/balance/route.ts +5 -2
  37. package/app/api/gateway/sync.ts +97 -14
  38. package/app/api/setup/agent-image/route.ts +14 -42
  39. package/app/api/version/route.ts +2 -1
  40. package/app/components/DashboardToolbar.tsx +1 -1
  41. package/app/gateway/PageClient.tsx +27 -32
  42. package/bin/rev4a.js +43 -41
  43. package/daemon.js +6 -6
  44. package/docs/ARCHITECTURE.md +107 -9
  45. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  46. package/docs/REV4A.md +54 -17
  47. package/docs/dev/API-REFERENCE.md +573 -105
  48. package/docs/dev/DATABASE.md +96 -0
  49. package/docs/dev/GATEWAY.md +21 -6
  50. package/docs/rag/DATA-FRESHNESS.md +6 -4
  51. package/docs/rag/GLOSSARY.md +12 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  53. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  54. package/instrumentation.ts +43 -0
  55. package/lib/agent-busy.ts +21 -0
  56. package/lib/agent-devices.ts +361 -0
  57. package/lib/agent-edit-state.ts +108 -0
  58. package/lib/agent-edit.ts +149 -0
  59. package/lib/agent-images.ts +375 -0
  60. package/lib/agent-ports-server.ts +78 -0
  61. package/lib/agent-ports.ts +91 -0
  62. package/lib/agent-recreate-state.ts +108 -0
  63. package/lib/agent-recreate.ts +359 -0
  64. package/lib/agent-restore-state.ts +107 -0
  65. package/lib/agent-restore.ts +141 -0
  66. package/lib/agent-setup.ts +66 -17
  67. package/lib/agent-update-state.ts +122 -0
  68. package/lib/agent-update.ts +456 -0
  69. package/lib/agent-versions.json +14 -0
  70. package/lib/agent-versions.ts +80 -0
  71. package/lib/buildAgentImage.ts +88 -290
  72. package/lib/channelManager.ts +149 -102
  73. package/lib/cold-backup.ts +354 -0
  74. package/lib/credentials/delivery.ts +3 -3
  75. package/lib/db-bootstrap.mjs +76 -0
  76. package/lib/docker-utils.ts +3 -3
  77. package/lib/provider-balance.ts +33 -12
  78. package/package.json +1 -1
@@ -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)
@@ -187,25 +123,27 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
187
123
  );
188
124
  }
189
125
 
190
- // Check if name is already in use by a running container
126
+ // Check if name is already in use by a container, running or not: a stopped agent
127
+ // keeps its name and its port block, so both would clash on start.
191
128
  const existing = execSync(
192
- `docker ps --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
129
+ `docker ps -a --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
193
130
  { encoding: 'utf-8', timeout: 5000 },
194
131
  ).trim();
195
132
  if (existing) {
196
133
  return NextResponse.json(
197
- { success: false, error: `Agent '${displayName}' is already running (container: ${existing})` },
134
+ { success: false, error: `Agent '${displayName}' already exists (container: ${existing})` },
198
135
  { status: 409 },
199
136
  );
200
137
  }
201
138
 
202
- // Resolve port range
139
+ // Resolve port range. One lookup, shared by both paths below.
140
+ const usedPorts = await getUsedHostPorts();
203
141
  let isBlock = false;
204
142
  let portStart: number = 0;
205
143
  let portEnd: number = 0;
206
144
 
207
145
  if (portInput) {
208
- const v = validatePortInput(portInput);
146
+ const v = validatePortInput(portInput, usedPorts);
209
147
  if (!v.valid) {
210
148
  return NextResponse.json(
211
149
  { success: false, error: v.error },
@@ -216,7 +154,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
216
154
  portStart = v.start;
217
155
  portEnd = v.end;
218
156
  } else {
219
- portStart = findAvailablePortBlock();
157
+ portStart = findAvailablePortBlock(usedPorts);
220
158
  portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
221
159
  isBlock = true;
222
160
  }
@@ -225,7 +163,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
225
163
  const mountFlags = getMountFlags();
226
164
 
227
165
  // Build docker run command
228
- const image = 'openclaw-agent-base:latest';
166
+ // The newest supported OpenClaw version downloaded here (lib/agent-images.ts).
167
+ let newestLocal: string | null = null;
168
+ try {
169
+ newestLocal = newestLocalSupportedVersion(await listLocalAgentImages());
170
+ } catch {
171
+ // Docker listed nothing usable: handled below as no image.
172
+ }
173
+ if (!newestLocal) {
174
+ return NextResponse.json(
175
+ { success: false, error: 'No supported agent image is downloaded. Download it from the Agents page first.' },
176
+ { status: 409 },
177
+ );
178
+ }
179
+ const image = localImageRef(newestLocal);
229
180
  const network = getNetwork();
230
181
 
231
182
  // Read agents token for new containers
@@ -241,9 +192,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
241
192
  // not ready yet; without it `docker run --network` fails. Idempotent.
242
193
  ensureNetworkExists(network);
243
194
 
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.
195
+ // Every agent gets a host port range for direct access. The Control UI link
196
+ // is built per click by /api/agents/[id]/open-control-ui, from the host the
197
+ // dashboard is reached on.
247
198
  //
248
199
  // There used to be an AGENT_HOSTNAME env var here, holding `<detected-ip>:<port>`.
249
200
  // Nothing ever read it: verified across this repo and across the whole
@@ -263,9 +214,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
263
214
 
264
215
  const labelOpts = labels.map((l) => `-l ${shellQuote(l)}`).join(' ');
265
216
  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`;
217
+ const portOpt = portMappingArgs(portStart, portEnd).join(' ');
269
218
 
270
219
  // Template files that will be copied into the container workspace.
271
220
  // Files that don't exist in the template dir are silently skipped —
@@ -370,14 +319,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
370
319
 
371
320
  // Merge all required sections in one pass (single write, single restart)
372
321
 
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.
322
+ // controlUi — keep what the image and OpenClaw wrote, and apply the browser-origin
323
+ // policy so the Control UI opens on whatever host the agent is reached on.
324
+ // Recreate re-applies the same policy through applyRuntimeConfig.
376
325
  const gatewaySection = (config.gateway || {}) as Record<string, unknown>;
377
- gatewaySection.controlUi = {
378
- allowedOrigins: ['*'],
379
- dangerouslyDisableDeviceAuth: true,
380
- };
326
+ gatewaySection.controlUi = withControlUiPolicy(gatewaySection.controlUi);
381
327
  config.gateway = gatewaySection;
382
328
 
383
329
  // Model reference (primary + fallbacks)
@@ -426,7 +372,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
426
372
  // create-specific config so they're always present regardless of
427
373
  // what was in the persistent volume.
428
374
  try {
429
- applyRuntimeConfig(containerName);
375
+ await applyRuntimeConfig(containerName);
430
376
 
431
377
  // Also guarantee models.providers.rev4a (host-dependent, not in base image).
432
378
  // 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
  }
@@ -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
  }
@@ -1,9 +1,8 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
5
2
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
3
  import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
4
+ import { containerOpenClawVersion, listLocalAgentImages, newestLocalSupportedVersion, type LocalAgentImage } from '@/lib/agent-images';
5
+ import { LOCAL_IMAGE_REPOSITORY, compareVersions } from '@/lib/agent-versions';
7
6
 
8
7
  export const dynamic = 'force-dynamic';
9
8
 
@@ -21,14 +20,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
21
20
  '/containers/json?all=true&filters={"label":["AGENT_ID"]}',
22
21
  );
23
22
 
24
- // Current image ID — used to detect outdated containers
25
- let currentImageId = '';
23
+ // Local agent images: each container's OpenClaw version, and the newest supported
24
+ // version downloaded, which decides `updateAvailable`.
25
+ let images: LocalAgentImage[] = [];
26
26
  try {
27
- const imageInspect = await dockerFetch('GET', '/images/openclaw-agent-base:latest/json');
28
- currentImageId = imageInspect.Id || '';
27
+ images = await listLocalAgentImages();
29
28
  } catch {
30
- // Image doesn't exist yet — all containers are fine
29
+ // No image list: versions come from inside running containers only.
31
30
  }
31
+ const newestLocal = newestLocalSupportedVersion(images);
32
32
 
33
33
  const agents = await Promise.all(
34
34
  containers.map(async (c: any) => {
@@ -36,7 +36,6 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
36
36
  let ip: string | null = null;
37
37
  let created: string | null = null;
38
38
  let env: string[] = [];
39
- let authToken: string | null = null;
40
39
  let controlPort: string | null = null;
41
40
  let portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> | null = null;
42
41
 
@@ -59,42 +58,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
59
58
  // Ports from inspect (HostConfig) — works even when container is stopped
60
59
  portBindings = inspect.HostConfig?.PortBindings || null;
61
60
 
62
- // Read auth token — always prefer the shared agents-token.json (user-managed)
63
- try {
64
- const tokenRaw = fs.readFileSync(
65
- AGENTS_TOKEN_FILE,
66
- 'utf-8'
67
- );
68
- const tokenData = JSON.parse(tokenRaw);
69
- if (tokenData.token) {
70
- authToken = tokenData.token;
71
- }
72
- } catch {
73
- // agents-token.json unavailable
74
- }
75
-
76
- // Fallback when the shared file is missing: the container's own token,
77
- // taken from the inspect data already fetched above.
78
- //
79
- // This used to `docker exec … grep OPENCLAW_GATEWAY_TOKEN /root/….env`
80
- // — a path containing a literal U+2026 ellipsis, so not a path at all.
81
- // Correcting the character wouldn't have helped: agent containers have
82
- // no .env file, and no file under /root/ holds that variable (verified
83
- // across the production fleet). The token exists only in the container
84
- // environment, which is right here in Config.Env. So the fallback spawned
85
- // a blocking process per agent to read a file that cannot exist, failed
86
- // silently, and left every "Open" button without its token — while the
87
- // value sat in memory, already fetched.
88
- if (!authToken) {
89
- const fromEnv = ((inspect.Config?.Env || []) as string[])
90
- .find((e) => e.startsWith('OPENCLAW_GATEWAY_TOKEN='));
91
- const token = fromEnv?.slice('OPENCLAW_GATEWAY_TOKEN='.length).trim();
92
- if (token && token !== 'undefined') authToken = token;
93
- }
94
-
95
61
  // The host port mapped to the agent's Control UI (container port 3000).
96
- // Only the port is returned: the browser composes the full URL, because
97
- // only the browser knows which hostname actually reaches this machine.
62
+ // No link and no token are returned: the Open buttons go through
63
+ // /api/agents/[id]/open-control-ui, which builds the link server-side.
98
64
  // Read from portBindings (works on a stopped container) with a fallback
99
65
  // to c.Ports, which is only populated while running.
100
66
  const port3000Binding = portBindings?.['3000/tcp'];
@@ -109,7 +75,17 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
109
75
  // Falls back to 'custom' if not set (agent created before this feature).
110
76
  const image: string = c.Image || '';
111
77
  let template: string | null = null;
112
- if (image.startsWith('openclaw-agent-base')) {
78
+ // Every container here carries AGENT_ID, so its version is looked up even when
79
+ // its image is not tagged `openclaw-agent-base` (an image left untagged, or
80
+ // pinned by id). The image name alone would miss those.
81
+ const openclawVersion = await containerOpenClawVersion(
82
+ { name: (c.Names?.[0] || '').replace(/^\//, ''), imageId: c.ImageID || '', running: c.State === 'running' },
83
+ images,
84
+ );
85
+ const isAgentBase = image.startsWith(LOCAL_IMAGE_REPOSITORY)
86
+ || images.some((img) => img.id === c.ImageID)
87
+ || openclawVersion !== null;
88
+ if (isAgentBase) {
113
89
  const templateLine = env.find((e: string) => e.startsWith('AGENT_TEMPLATE='));
114
90
  template = templateLine ? templateLine.split('=').slice(1).join('=') : 'custom';
115
91
  }
@@ -147,10 +123,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
147
123
  created,
148
124
  env,
149
125
  controlPort,
150
- authToken,
151
- // Flag as outdated when the container's image differs from the current base image.
152
- // We compare raw ImageID digests — no fragile name-based filtering.
153
- imageOutdated: currentImageId ? c.ImageID !== currentImageId : false,
126
+ openclawVersion,
127
+ // A newer supported OpenClaw version is downloaded. Recreate keeps the agent's
128
+ // version; moving to the newer one is a separate, backed-up update.
129
+ updateAvailable: !!(openclawVersion && newestLocal && compareVersions(newestLocal, openclawVersion) > 0),
154
130
  };
155
131
  }),
156
132
  );
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import { NextResponse, type NextRequest } from 'next/server';
4
4
  import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
5
5
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+ import { agentBusyReason } from '@/lib/agent-busy';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
8
9
 
@@ -57,37 +58,59 @@ export async function PUT(request: NextRequest): Promise<NextResponse> {
57
58
  fs.writeFileSync(AGENTS_TOKEN_FILE, JSON.stringify(payload, null, 2));
58
59
 
59
60
  let containersUpdated = 0;
60
- let containerNames: string[] = [];
61
+ /** Containers left alone because an operation is running on them. */
62
+ const skipped: string[] = [];
63
+ /** Containers that could not be updated, with the reason. */
64
+ const failed: { container: string; error: string }[] = [];
65
+ let containers: { name: string; agentId: string }[] = [];
61
66
 
62
67
  try {
63
68
  const raw = execSync(
64
- `docker ps --filter "label=AGENT_ID" --format "{{.Names}}"`,
69
+ `docker ps --filter "label=AGENT_ID" --format '{{.Names}} {{.Label "AGENT_ID"}}'`,
65
70
  { encoding: 'utf-8', timeout: 5000 },
66
71
  ).trim();
67
- containerNames = raw ? raw.split('\n').map((name) => name.trim()).filter(Boolean) : [];
72
+ containers = raw
73
+ ? raw.split('\n').map((line) => {
74
+ const [name, agentId] = line.trim().split(/\s+/);
75
+ return { name: (name ?? '').trim(), agentId: (agentId ?? '').trim() };
76
+ }).filter((c) => c.name)
77
+ : [];
68
78
  } catch {
69
- containerNames = [];
79
+ containers = [];
70
80
  }
71
81
 
72
- for (const containerName of containerNames) {
82
+ for (const { name, agentId } of containers) {
73
83
  try {
84
+ // Restarting an agent mid-update, mid-recreate, mid-restore or mid-edit would cut
85
+ // the operation in half: leave it and say so.
86
+ if (agentId && (await agentBusyReason(agentId))) {
87
+ skipped.push(name);
88
+ continue;
89
+ }
74
90
  // Write token to /root/.agent-token (entrypoint reads this, overrides env var)
75
91
  execSync(
76
- `docker exec ${shellQuote(containerName)} sh -c 'echo ${shellQuote(token)} > /root/.agent-token'`,
92
+ `docker exec ${shellQuote(name)} sh -c 'echo ${shellQuote(token)} > /root/.agent-token'`,
77
93
  { encoding: 'utf-8', timeout: 10000 },
78
94
  );
79
95
  // Restart to pick up new token
80
- execSync(`docker restart ${shellQuote(containerName)}`, {
96
+ execSync(`docker restart ${shellQuote(name)}`, {
81
97
  encoding: 'utf-8',
82
98
  timeout: 20000,
83
99
  });
84
100
  containersUpdated += 1;
85
- } catch {
86
- // Skip failed containers and continue syncing the rest.
101
+ } catch (e) {
102
+ // Report the failure instead of swallowing it: a token that reached only some
103
+ // agents used to look like a full success.
104
+ failed.push({ container: name, error: (e as Error)?.message ?? String(e) });
87
105
  }
88
106
  }
89
107
 
90
- return NextResponse.json({ success: true, containersUpdated });
108
+ return NextResponse.json({
109
+ success: failed.length === 0,
110
+ containersUpdated,
111
+ ...(skipped.length ? { skipped } : {}),
112
+ ...(failed.length ? { failed } : {}),
113
+ });
91
114
  } catch (e: unknown) {
92
115
  return NextResponse.json(
93
116
  { success: false, error: (e as Error).message || 'Unknown error' },