@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.
- package/README.md +3 -1
- 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 +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- 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]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- 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 -169
- 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 +91 -145
- 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/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- 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 +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -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 +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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) +
|
|
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
|
-
|
|
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 '${
|
|
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
|
-
|
|
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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
325
|
-
//
|
|
326
|
-
// a zombie agent with no config is worse than an explicit failure.
|
|
327
|
-
const ready =
|
|
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
|
|
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 —
|
|
381
|
-
//
|
|
382
|
-
//
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
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
|
-
|
|
491
|
-
return config.some((m) => m.id === modelId);
|
|
435
|
+
execFileSync('docker', ['network', 'inspect', name], { stdio: 'ignore', timeout: 5000 });
|
|
492
436
|
} catch {
|
|
493
|
-
|
|
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
|
-
*
|
|
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
|
}
|