@flame0510/project-aether 1.1.13 → 1.1.15
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/app/agents/PageClient.tsx +56 -12
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +17 -10
- package/app/api/agents/image-status/route.ts +92 -85
- package/app/api/agents/route.ts +25 -29
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/route.ts +102 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/skills/route.js +88 -96
- package/app/components/Skeleton.tsx +71 -0
- package/app/crons/PageClient.tsx +32 -7
- package/docs/ARCHITECTURE.md +0 -1
- package/docs/dev/API-REFERENCE.md +4 -4
- package/lib/channelManager.ts +23 -16
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/package.json +1 -1
|
@@ -42,11 +42,30 @@ interface Agent {
|
|
|
42
42
|
ip: string | null;
|
|
43
43
|
created: string | null;
|
|
44
44
|
env: string[];
|
|
45
|
-
|
|
45
|
+
controlPort: string | null;
|
|
46
46
|
authToken: string | null;
|
|
47
47
|
imageOutdated?: boolean;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* URL of the agent's OpenClaw Control UI.
|
|
52
|
+
*
|
|
53
|
+
* Composed here rather than on the server on purpose. The hostname the dashboard
|
|
54
|
+
* is currently being reached on is, by definition, one that resolves and routes
|
|
55
|
+
* for this user — `localhost` on a dev machine, a LAN address from a phone, the
|
|
56
|
+
* VPS IP, or a domain. The server can only guess, and it guessed badly: it shelled
|
|
57
|
+
* out to `curl icanhazip.com` on every call and, on macOS, ended up returning the
|
|
58
|
+
* machine's `.local` mDNS name.
|
|
59
|
+
*
|
|
60
|
+
* Returns null when the agent has no published port (nothing to link to) or when
|
|
61
|
+
* called during a server render, where `window` doesn't exist.
|
|
62
|
+
*/
|
|
63
|
+
function agentControlUrl(agent: Agent): string | null {
|
|
64
|
+
if (!agent.controlPort || typeof window === 'undefined') return null;
|
|
65
|
+
const base = `http://${window.location.hostname}:${agent.controlPort}`;
|
|
66
|
+
return agent.authToken ? `${base}#token=${encodeURIComponent(agent.authToken)}` : base;
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
type FilterState = 'all' | 'running' | 'exited';
|
|
51
70
|
|
|
52
71
|
const STATUS_BULLET: Record<string, string> = {
|
|
@@ -180,11 +199,17 @@ function AgentListItem({
|
|
|
180
199
|
)}
|
|
181
200
|
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
182
201
|
{loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
|
|
183
|
-
{agent.state === 'running' && agent.
|
|
202
|
+
{agent.state === 'running' && agent.controlPort && (
|
|
184
203
|
<Button
|
|
185
204
|
variant="primary"
|
|
186
205
|
size="sm"
|
|
187
|
-
|
|
206
|
+
// Gate on the port, resolve the URL on click: the condition stays
|
|
207
|
+
// safe to evaluate during a server render, the URL never is.
|
|
208
|
+
onClick={(e) => {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
const url = agentControlUrl(agent);
|
|
211
|
+
if (url) window.open(url, '_blank');
|
|
212
|
+
}}
|
|
188
213
|
title="Open agent Control UI"
|
|
189
214
|
>
|
|
190
215
|
Open ↗
|
|
@@ -231,6 +256,8 @@ export default function AgentsPageClient() {
|
|
|
231
256
|
const [tokenStatus, setTokenStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
232
257
|
const [lifecycleLoading, setLifecycleLoading] = useState<string | null>(null);
|
|
233
258
|
const skipNextPoll = useRef(false);
|
|
259
|
+
/** Guards the un-awaited channel-summary fetch against out-of-order responses. */
|
|
260
|
+
const chSummarySeq = useRef(0);
|
|
234
261
|
useEffect(() => {
|
|
235
262
|
const check = () => setIsMobile(window.innerWidth < 768);
|
|
236
263
|
check();
|
|
@@ -246,11 +273,23 @@ export default function AgentsPageClient() {
|
|
|
246
273
|
if (!Array.isArray(data)) throw new Error('Invalid response');
|
|
247
274
|
setAgents(data);
|
|
248
275
|
setError('');
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
276
|
+
|
|
277
|
+
// Channel summary is deliberately not awaited: it only decorates rows with
|
|
278
|
+
// a TG badge, and awaiting it here held the whole page behind a second
|
|
279
|
+
// round trip — the agent list was ready in ~0.25s but stayed hidden for
|
|
280
|
+
// ~1.15s. It now lands on its own and the badges appear when it does.
|
|
281
|
+
//
|
|
282
|
+
// Sequence-guarded: load() also runs on a 15s poll, so several of these can
|
|
283
|
+
// be in flight at once. Without the check, a slow earlier response landing
|
|
284
|
+
// after a faster later one would overwrite fresh badges with stale ones.
|
|
285
|
+
const seq = ++chSummarySeq.current;
|
|
286
|
+
void (async () => {
|
|
287
|
+
try {
|
|
288
|
+
const chRes = await fetch('/api/agents/channels-summary');
|
|
289
|
+
if (!chRes.ok || seq !== chSummarySeq.current) return;
|
|
290
|
+
setChSummary(await chRes.json());
|
|
291
|
+
} catch { /* badges are optional */ }
|
|
292
|
+
})();
|
|
254
293
|
} catch (e: any) {
|
|
255
294
|
setError(e.message || 'Loading error');
|
|
256
295
|
} finally {
|
|
@@ -742,6 +781,11 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
742
781
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
743
782
|
const [showEdit, setShowEdit] = useState(false);
|
|
744
783
|
|
|
784
|
+
// Safe to resolve during render: this panel only mounts once the agent list has
|
|
785
|
+
// been fetched client-side, so it never runs in a server render. The helper
|
|
786
|
+
// guards for a missing `window` regardless.
|
|
787
|
+
const controlUrl = agentControlUrl(agent);
|
|
788
|
+
|
|
745
789
|
const handleDelete = async () => {
|
|
746
790
|
setDeleting(true);
|
|
747
791
|
setDeleteError('');
|
|
@@ -834,15 +878,15 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
|
834
878
|
{/* Backup - persistent volume backup & restore */}
|
|
835
879
|
<BackupSection agentName={agent.name} />
|
|
836
880
|
|
|
837
|
-
{/* Access - agent URL (token inline in link) */}
|
|
838
|
-
{
|
|
881
|
+
{/* Access - agent Control UI URL (token inline in link) */}
|
|
882
|
+
{controlUrl && (
|
|
839
883
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
840
884
|
<SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
|
|
841
885
|
<div style={{ padding: '7px 14px', fontSize: 12 }}>
|
|
842
886
|
<div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
|
|
843
|
-
<a href={
|
|
887
|
+
<a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
844
888
|
style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
|
|
845
|
-
>{
|
|
889
|
+
>{controlUrl}</a>
|
|
846
890
|
|
|
847
891
|
</div>
|
|
848
892
|
</div>
|
|
@@ -77,9 +77,16 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
77
77
|
const [fallbacks] = useState<string[]>([]);
|
|
78
78
|
const [submitting, setSubmitting] = useState(false);
|
|
79
79
|
const [errorMsg, setErrorMsg] = useState('');
|
|
80
|
-
const [result, setResult] = useState<{ name: string; containerId: string;
|
|
80
|
+
const [result, setResult] = useState<{ name: string; containerId: string; port: number; controlToken: string } | null>(null);
|
|
81
81
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
82
82
|
|
|
83
|
+
// The agent's Control UI URL, composed here rather than server-side: whatever
|
|
84
|
+
// hostname reached this dashboard is one that resolves and routes for this
|
|
85
|
+
// user, which the server can only guess at. Null until the create succeeds.
|
|
86
|
+
const controlUrl = result && typeof window !== 'undefined'
|
|
87
|
+
? `http://${window.location.hostname}:${result.port}#token=${encodeURIComponent(result.controlToken)}`
|
|
88
|
+
: null;
|
|
89
|
+
|
|
83
90
|
const handleSelectTemplate = (id: string) => {
|
|
84
91
|
setSelectedTemplate(id);
|
|
85
92
|
setAgentName('');
|
|
@@ -389,10 +396,10 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
389
396
|
<div style={{ fontSize: 13, color: '#888', marginBottom: 4 }}>
|
|
390
397
|
Name: <span style={{ color: 'var(--text)' }}>{result.name}</span>
|
|
391
398
|
</div>
|
|
392
|
-
{
|
|
399
|
+
{controlUrl && (
|
|
393
400
|
<div style={{ fontSize: 13, color: '#888' }}>
|
|
394
|
-
Access: <a href={
|
|
395
|
-
style={{ color: '#60a5fa', textDecoration: 'underline' }}>{
|
|
401
|
+
Access: <a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
402
|
+
style={{ color: '#60a5fa', textDecoration: 'underline' }}>{controlUrl}</a>
|
|
396
403
|
</div>
|
|
397
404
|
)}
|
|
398
405
|
</div>
|
|
@@ -181,9 +181,9 @@ export async function POST(request: Request,
|
|
|
181
181
|
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
182
182
|
);
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
// No gateway restart: `config patch` reports "Change will apply without
|
|
185
|
+
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
186
|
+
// openclaw.json with gateway.reload defaulting to hybrid.
|
|
187
187
|
} catch {
|
|
188
188
|
// config patch is best-effort on recreate
|
|
189
189
|
}
|
|
@@ -265,7 +265,9 @@ export async function PATCH(
|
|
|
265
265
|
`docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
|
|
266
266
|
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
267
267
|
);
|
|
268
|
-
|
|
268
|
+
// No gateway restart: `config patch` reports "Change will apply without
|
|
269
|
+
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
270
|
+
// openclaw.json with gateway.reload defaulting to hybrid.
|
|
269
271
|
} catch { /* non-fatal */ }
|
|
270
272
|
|
|
271
273
|
return NextResponse.json({
|
|
@@ -1,36 +1,67 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { parseTelegramChannel } from '@/lib/channelManager';
|
|
5
|
+
import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
4
6
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
7
|
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
6
10
|
export const dynamic = 'force-dynamic';
|
|
7
11
|
export const maxDuration = 15; // 15-second timeout for channels summary
|
|
8
12
|
|
|
13
|
+
/** Agents read at once — each is one `docker exec … cat`. */
|
|
14
|
+
const AGENT_QUERY_CONCURRENCY = 8;
|
|
15
|
+
|
|
16
|
+
const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* GET /api/agents/channels-summary
|
|
11
20
|
*
|
|
12
21
|
* Returns a lightweight channel status map for all running agents.
|
|
13
22
|
* { [agentId]: { telegram: boolean } }
|
|
23
|
+
*
|
|
24
|
+
* This used to call getChannels(agentId) in a loop, which re-ran
|
|
25
|
+
* `docker ps --filter label=AGENT_ID=<id>` for every agent just to recover the
|
|
26
|
+
* container name — a name this route's own `docker ps` had already returned and
|
|
27
|
+
* then discarded. So it was 1 + 2N synchronous process spawns to read N files.
|
|
28
|
+
* Now the discovery call carries the container name through, and the configs are
|
|
29
|
+
* read concurrently.
|
|
14
30
|
*/
|
|
15
|
-
export async function GET(request: NextRequest
|
|
16
|
-
): Promise<NextResponse> {
|
|
31
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
17
32
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
18
33
|
try {
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
34
|
+
// Keep the container name alongside the label — the follow-up lookups per
|
|
35
|
+
// agent existed only because this call used to throw the name away.
|
|
36
|
+
const { stdout } = await execFileAsync('docker', [
|
|
37
|
+
'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}',
|
|
38
|
+
], { encoding: 'utf-8', timeout: 5000 });
|
|
24
39
|
|
|
25
|
-
const
|
|
26
|
-
|
|
40
|
+
const agents = stdout.trim().split('\n')
|
|
41
|
+
.map((line) => {
|
|
42
|
+
const [agentId, containerName] = line.split('\t');
|
|
43
|
+
if (!agentId?.trim() || !containerName?.trim()) return null;
|
|
44
|
+
return { agentId: agentId.trim(), containerName: containerName.trim() };
|
|
45
|
+
})
|
|
46
|
+
.filter(Boolean) as { agentId: string; containerName: string }[];
|
|
47
|
+
|
|
48
|
+
const results = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, async (agent) => {
|
|
49
|
+
const raw = await dockerExecNoFail(
|
|
50
|
+
agent.containerName,
|
|
51
|
+
['cat', AGENT_CONFIG_PATH],
|
|
52
|
+
{ timeoutMs: 8000 },
|
|
53
|
+
);
|
|
54
|
+
if (!raw) return { agentId: agent.agentId, telegram: false };
|
|
55
|
+
try {
|
|
56
|
+
const config = JSON.parse(raw) as Record<string, unknown>;
|
|
57
|
+
return { agentId: agent.agentId, telegram: parseTelegramChannel(config)?.connected === true };
|
|
58
|
+
} catch {
|
|
59
|
+
return { agentId: agent.agentId, telegram: false };
|
|
60
|
+
}
|
|
61
|
+
});
|
|
27
62
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
summary[agentId] = {
|
|
31
|
-
telegram: channels.telegram?.connected === true,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
63
|
+
const summary: Record<string, { telegram: boolean }> = {};
|
|
64
|
+
for (const r of results) summary[r.agentId] = { telegram: r.telegram };
|
|
34
65
|
|
|
35
66
|
return NextResponse.json(summary);
|
|
36
67
|
} catch {
|
|
@@ -4,7 +4,6 @@ import * as crypto from 'crypto';
|
|
|
4
4
|
import * as fs from 'fs';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import { buildRev4aProviderConfig } from '../../gateway/sync';
|
|
7
|
-
import { detectPublicHost } from '@/lib/docker-utils';
|
|
8
7
|
import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
|
|
9
8
|
import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
|
|
10
9
|
import { loadModelsConfig } from '@/lib/model-catalogue';
|
|
@@ -249,17 +248,23 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
249
248
|
);
|
|
250
249
|
}
|
|
251
250
|
|
|
252
|
-
// Every agent gets a host port range for direct access
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
// Every agent gets a host port range for direct access. The URL is composed
|
|
252
|
+
// by the browser from `port` + `controlToken` in the response — only the
|
|
253
|
+
// browser knows a hostname that actually reaches this machine.
|
|
254
|
+
//
|
|
255
|
+
// There used to be an AGENT_HOSTNAME env var here, holding `<detected-ip>:<port>`.
|
|
256
|
+
// Nothing ever read it: verified across this repo and across the whole
|
|
257
|
+
// filesystem of a running agent container, binaries included, with a control
|
|
258
|
+
// search proving the method finds AGENT_ID and OPENCLAW_GATEWAY_TOKEN in the
|
|
259
|
+
// same sweep. Worse, a container's env is immutable and recreate copies every
|
|
260
|
+
// AGENT_* var forward, so a wrong address was frozen in and propagated for the
|
|
261
|
+
// life of the agent, with no code path anywhere that could refresh it.
|
|
256
262
|
const labels: string[] = [`AGENT_ID=${containerName}`];
|
|
257
263
|
const envVars: string[] = [
|
|
258
264
|
`AGENT_ID=${containerName}`,
|
|
259
265
|
`AGENT_NAME=${displayName.trim()}`,
|
|
260
266
|
`AGENT_TEMPLATE=${template}`,
|
|
261
267
|
`OPENCLAW_GATEWAY_TOKEN=${agentsToken}`,
|
|
262
|
-
`AGENT_HOSTNAME=${agentHostname}`,
|
|
263
268
|
`TZ=Europe/Rome`,
|
|
264
269
|
];
|
|
265
270
|
|
|
@@ -437,9 +442,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
437
442
|
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
438
443
|
);
|
|
439
444
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
445
|
+
// No gateway restart: `config patch` reports "Change will apply without
|
|
446
|
+
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
447
|
+
// openclaw.json with gateway.reload defaulting to hybrid. The earlier
|
|
448
|
+
// restart in this route stays — that one follows a full-file rewrite that
|
|
449
|
+
// includes gateway.*, which is the one section that genuinely needs it.
|
|
443
450
|
} catch (e: unknown) {
|
|
444
451
|
// runtime config patch is best-effort; log but don't fail the create
|
|
445
452
|
console.error('[agent:create] runtime config patch failed:', (e as Error).message || String(e));
|
|
@@ -452,7 +459,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
452
459
|
displayName: displayName.trim(),
|
|
453
460
|
image,
|
|
454
461
|
network,
|
|
455
|
-
|
|
462
|
+
controlToken: agentsToken,
|
|
456
463
|
port: portStart,
|
|
457
464
|
portEnd,
|
|
458
465
|
isBlock,
|
|
@@ -2,127 +2,134 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
/**
|
|
3
3
|
* GET /api/agents/image-status
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
7
|
+
*
|
|
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.
|
|
8
27
|
*/
|
|
9
28
|
import { NextResponse } from 'next/server';
|
|
10
|
-
import {
|
|
29
|
+
import { execFile } from 'child_process';
|
|
30
|
+
import { promisify } from 'util';
|
|
11
31
|
import { getIsDownloading } from '@/lib/buildAgentImage';
|
|
12
32
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
33
|
+
import { dockerAvailable } from '@/lib/docker-socket';
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
13
36
|
|
|
14
37
|
export const dynamic = 'force-dynamic';
|
|
15
38
|
|
|
16
39
|
const REGISTRY_IMAGE = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
|
|
17
40
|
const LOCAL_IMAGE = 'openclaw-agent-base:latest';
|
|
41
|
+
const REGISTRY_PREFIX = 'ghcr.io/flame0510/rev4a/openclaw-agent-base@';
|
|
18
42
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return true;
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
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;
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
execSync(
|
|
35
|
-
`docker image inspect ${LOCAL_IMAGE} --format "{{.ID}}" 2>/dev/null`,
|
|
36
|
-
{ timeout: 3000 },
|
|
37
|
-
);
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
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;
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
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> {
|
|
46
56
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
};
|
|
51
68
|
} catch {
|
|
52
69
|
return null;
|
|
53
70
|
}
|
|
54
71
|
}
|
|
55
72
|
|
|
56
|
-
/**
|
|
57
|
-
function getRemoteDigest(): string | null {
|
|
58
|
-
if (
|
|
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
|
+
|
|
59
94
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Use the manifest descriptor digest (comparable with RepoDigests),
|
|
66
|
-
// not config.digest (which is a different hash).
|
|
67
|
-
return manifest?.Descriptor?.digest || null;
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
95
|
+
const digest = await remoteInFlight;
|
|
96
|
+
remoteCache = { digest, at: Date.now() };
|
|
97
|
+
return digest;
|
|
98
|
+
} finally {
|
|
99
|
+
remoteInFlight = null;
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
74
104
|
const denied = await requireAuthJWT(request);
|
|
75
105
|
if (denied) return denied;
|
|
76
|
-
|
|
77
|
-
const localId = getLocalImageId();
|
|
106
|
+
|
|
78
107
|
const downloading = getIsDownloading();
|
|
79
108
|
|
|
80
|
-
if (!
|
|
81
|
-
return NextResponse.json({
|
|
82
|
-
exists: false,
|
|
83
|
-
needsUpdate: true,
|
|
84
|
-
downloading,
|
|
85
|
-
localId: null,
|
|
86
|
-
});
|
|
109
|
+
if (!dockerAvailable()) {
|
|
110
|
+
return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
|
|
87
111
|
}
|
|
88
112
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
const local = await inspectLocalImage();
|
|
114
|
+
if (!local) {
|
|
115
|
+
return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
|
|
116
|
+
}
|
|
93
117
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
const inspect = JSON.parse(
|
|
99
|
-
execSync(
|
|
100
|
-
`docker image inspect ${LOCAL_IMAGE} --format '{{json .}}' 2>/dev/null`,
|
|
101
|
-
{ encoding: 'utf-8', timeout: 3000 },
|
|
102
|
-
),
|
|
103
|
-
);
|
|
104
|
-
const localRepoDigests: string[] = inspect?.RepoDigests || [];
|
|
105
|
-
const registryDigest = localRepoDigests.find((d: string) =>
|
|
106
|
-
d.startsWith('ghcr.io/flame0510/rev4a/openclaw-agent-base@'),
|
|
107
|
-
);
|
|
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();
|
|
108
121
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Image was built locally, never pulled — needs update
|
|
115
|
-
needsUpdate = true;
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// Can't determine — assume up to date
|
|
119
|
-
}
|
|
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;
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
return NextResponse.json({
|
|
123
130
|
exists: true,
|
|
124
131
|
needsUpdate,
|
|
125
132
|
downloading,
|
|
126
|
-
localId,
|
|
133
|
+
localId: local.id || null,
|
|
127
134
|
});
|
|
128
135
|
}
|
package/app/api/agents/route.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { detectPublicHost } from '@/lib/docker-utils';
|
|
5
4
|
import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
|
|
6
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
7
6
|
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
@@ -38,7 +37,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
38
37
|
let created: string | null = null;
|
|
39
38
|
let env: string[] = [];
|
|
40
39
|
let authToken: string | null = null;
|
|
41
|
-
let
|
|
40
|
+
let controlPort: string | null = null;
|
|
42
41
|
let portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> | null = null;
|
|
43
42
|
|
|
44
43
|
try {
|
|
@@ -61,7 +60,6 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
61
60
|
portBindings = inspect.HostConfig?.PortBindings || null;
|
|
62
61
|
|
|
63
62
|
// Read auth token — always prefer the shared agents-token.json (user-managed)
|
|
64
|
-
const cName = (c.Names?.[0] || '').replace(/^\//, '');
|
|
65
63
|
try {
|
|
66
64
|
const tokenRaw = fs.readFileSync(
|
|
67
65
|
AGENTS_TOKEN_FILE,
|
|
@@ -75,36 +73,34 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
75
73
|
// agents-token.json unavailable
|
|
76
74
|
}
|
|
77
75
|
|
|
78
|
-
// Fallback
|
|
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.
|
|
79
88
|
if (!authToken) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{ timeout: 5000, encoding: 'utf-8' }
|
|
85
|
-
).toString().trim();
|
|
86
|
-
if (token && token !== 'undefined') authToken = token;
|
|
87
|
-
} catch {
|
|
88
|
-
// token unavailable for this container
|
|
89
|
-
}
|
|
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;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
95
|
+
// 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.
|
|
98
|
+
// Read from portBindings (works on a stopped container) with a fallback
|
|
99
|
+
// to c.Ports, which is only populated while running.
|
|
96
100
|
const port3000Binding = portBindings?.['3000/tcp'];
|
|
97
|
-
|
|
98
|
-
|| (c.Ports || []).find((p: any) => p.PublicPort && p.PrivatePort === 3000)?.PublicPort
|
|
101
|
+
controlPort = port3000Binding?.[0]?.HostPort
|
|
102
|
+
|| (c.Ports || []).find((p: any) => p.PublicPort && p.PrivatePort === 3000)?.PublicPort?.toString()
|
|
99
103
|
|| null;
|
|
100
|
-
if (publicPort) {
|
|
101
|
-
const host = detectPublicHost();
|
|
102
|
-
let baseUrl = `http://${host}:${publicPort}`;
|
|
103
|
-
if (authToken) {
|
|
104
|
-
baseUrl += `#token=${encodeURIComponent(authToken)}`;
|
|
105
|
-
}
|
|
106
|
-
traefikUrl = baseUrl;
|
|
107
|
-
}
|
|
108
104
|
} catch {
|
|
109
105
|
// inspect non-critical, continue with partial data
|
|
110
106
|
}
|
|
@@ -150,7 +146,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
150
146
|
ip,
|
|
151
147
|
created,
|
|
152
148
|
env,
|
|
153
|
-
|
|
149
|
+
controlPort,
|
|
154
150
|
authToken,
|
|
155
151
|
// Flag as outdated when the container's image differs from the current base image.
|
|
156
152
|
// We compare raw ImageID digests — no fragile name-based filtering.
|