@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.
- package/README.md +1 -0
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +4 -1
- package/app/agents/PageClient.tsx +629 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +14 -28
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -163
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -150
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +38 -92
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +2 -2
- package/app/api/gateway/agent/route.ts +14 -0
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/sync.ts +97 -14
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/api/version/route.ts +2 -1
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/gateway/PageClient.tsx +27 -32
- package/bin/rev4a.js +43 -41
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +107 -9
- package/docs/FRONTEND-ARCHITECTURE.md +8 -1
- package/docs/REV4A.md +54 -17
- package/docs/dev/API-REFERENCE.md +573 -105
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +21 -6
- package/docs/rag/DATA-FRESHNESS.md +6 -4
- package/docs/rag/GLOSSARY.md +12 -3
- package/docs/rag/REV4A-OVERVIEW.md +18 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
- package/instrumentation.ts +43 -0
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +149 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +78 -0
- package/lib/agent-ports.ts +91 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +359 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +141 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +456 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +149 -102
- package/lib/cold-backup.ts +354 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/provider-balance.ts +33 -12
- 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
|
-
|
|
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
|
|
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}'
|
|
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
|
-
|
|
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
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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 =
|
|
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 —
|
|
374
|
-
//
|
|
375
|
-
//
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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({
|
|
30
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
24
|
+
const base = { downloading: getIsDownloading(), downloadingVersion: getDownloadingVersion() };
|
|
108
25
|
|
|
109
26
|
if (!dockerAvailable()) {
|
|
110
|
-
return NextResponse.json({
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
...base, exists: false, needsUpdate: true, localVersions: [], newestLocal: null, available: null, registryReachable: false,
|
|
29
|
+
});
|
|
111
30
|
}
|
|
112
31
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
}
|
package/app/api/agents/route.ts
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
currentImageId = imageInspect.Id || '';
|
|
27
|
+
images = await listLocalAgentImages();
|
|
29
28
|
} catch {
|
|
30
|
-
//
|
|
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
|
-
//
|
|
97
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
69
|
+
`docker ps --filter "label=AGENT_ID" --format '{{.Names}} {{.Label "AGENT_ID"}}'`,
|
|
65
70
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
66
71
|
).trim();
|
|
67
|
-
|
|
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
|
-
|
|
79
|
+
containers = [];
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
for (const
|
|
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(
|
|
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(
|
|
96
|
+
execSync(`docker restart ${shellQuote(name)}`, {
|
|
81
97
|
encoding: 'utf-8',
|
|
82
98
|
timeout: 20000,
|
|
83
99
|
});
|
|
84
100
|
containersUpdated += 1;
|
|
85
|
-
} catch {
|
|
86
|
-
//
|
|
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({
|
|
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' },
|