@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.
@@ -42,11 +42,30 @@ interface Agent {
42
42
  ip: string | null;
43
43
  created: string | null;
44
44
  env: string[];
45
- traefikUrl: string | null;
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.traefikUrl && (
202
+ {agent.state === 'running' && agent.controlPort && (
184
203
  <Button
185
204
  variant="primary"
186
205
  size="sm"
187
- onClick={(e) => { e.stopPropagation(); window.open(agent.traefikUrl!, '_blank'); }}
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
- // Also load channel summary
250
- try {
251
- const chRes = await fetch('/api/agents/channels-summary');
252
- if (chRes.ok) setChSummary(await chRes.json());
253
- } catch { /* */ }
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
- {agent.traefikUrl && (
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={agent.traefikUrl} target="_blank" rel="noopener noreferrer"
887
+ <a href={controlUrl} target="_blank" rel="noopener noreferrer"
844
888
  style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
845
- >{agent.traefikUrl}</a>
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; traefikUrl: string } | null>(null);
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
- {result.traefikUrl && (
399
+ {controlUrl && (
393
400
  <div style={{ fontSize: 13, color: '#888' }}>
394
- Access: <a href={result.traefikUrl} target="_blank" rel="noopener noreferrer"
395
- style={{ color: '#60a5fa', textDecoration: 'underline' }}>{result.traefikUrl}</a>
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
- try {
185
- execSync(`docker exec ${id} openclaw gateway restart`, { timeout: 15000 });
186
- } catch { /* non-fatal */ }
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
- try { execSync(`docker exec ${id} openclaw gateway restart`, { timeout: 15000 }); } catch { /* non-fatal */ }
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 { getChannels } from '@/lib/channelManager';
3
- import { execSync } from 'child_process';
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
- // Discover agents with AGENT_ID label
20
- const out = execSync(
21
- `docker ps --filter "label=AGENT_ID" --format '{{.Label "AGENT_ID"}}' 2>/dev/null`,
22
- { encoding: 'utf-8', timeout: 5000 },
23
- ).trim();
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 agentIds = out ? out.split('\n').filter(Boolean) : [];
26
- const summary: Record<string, { telegram: boolean }> = {};
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
- for (const agentId of agentIds) {
29
- const channels = getChannels(agentId);
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 (http://<ip>:<port>).
253
- const agentHostname = `${detectPublicHost()}:${portStart}`;
254
- const traefikUrl = `http://${agentHostname}#token=${encodeURIComponent(agentsToken)}`;
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
- try {
441
- execSync(`docker exec ${containerName} openclaw gateway restart`, { timeout: 15000 });
442
- } catch { /* restart is best-effort */ }
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
- traefikUrl,
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
- * Checks whether the openclaw-agent-base image is present and up to date.
6
- * Compares the local image digest with the remote registry digest
7
- * (public image on ghcr.io, no auth needed).
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 { execSync } from 'child_process';
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
- function dockerOk(): boolean {
20
- try {
21
- execSync('docker version --format "{{.Client.Version}}" 2>/dev/null', {
22
- encoding: 'utf-8',
23
- timeout: 2000,
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
- function localImageExists(): boolean {
32
- if (!dockerOk()) return false;
33
- try {
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
- function getLocalImageId(): string | null {
45
- if (!dockerOk()) return null;
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
- return execSync(
48
- `docker image inspect ${LOCAL_IMAGE} --format "{{.ID}}" 2>/dev/null`,
49
- { encoding: 'utf-8', timeout: 3000 },
50
- ).trim() || null;
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
- /** Fetch the remote manifest digest from ghcr.io (public, no auth). */
57
- function getRemoteDigest(): string | null {
58
- if (!dockerOk()) return null;
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 out = execSync(
61
- `docker manifest inspect ${REGISTRY_IMAGE} --verbose 2>/dev/null`,
62
- { encoding: 'utf-8', timeout: 15000 },
63
- );
64
- const manifest = JSON.parse(out);
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
- const exists = localImageExists();
77
- const localId = getLocalImageId();
106
+
78
107
  const downloading = getIsDownloading();
79
108
 
80
- if (!exists) {
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
- // Compare local image digest with remote registry digest.
90
- // If they differ, a newer image is available.
91
- let needsUpdate = false;
92
- const remoteDigest = getRemoteDigest();
113
+ const local = await inspectLocalImage();
114
+ if (!local) {
115
+ return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
116
+ }
93
117
 
94
- if (remoteDigest && localId) {
95
- // localId is the image ID (e.g. sha256:abc123...).
96
- // Extract it from any RepoDigest that references the registry.
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
- if (registryDigest) {
110
- // Extract sha256:... from "ghcr.io/...@sha256:xxx"
111
- const localDigest = registryDigest.split('@')[1] || '';
112
- needsUpdate = localDigest !== remoteDigest;
113
- } else {
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
  }
@@ -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 traefikUrl: string | null = null;
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 only if shared token is missing: try the container's own gateway token
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
- try {
81
- const { execSync } = require('child_process');
82
- let token = execSync(
83
- `docker exec ${cName} sh -c 'grep ^OPENCLAW_GATEWAY_TOKEN= /root/….env 2>/dev/null | cut -d= -f2- | tr -d "\"\""' 2>/dev/null || echo ''`,
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
- // Build the agent URL from the host's public IP + mapped port.
93
- // Every agent has -p <port>:3000, so we look for the public port
94
- // that maps to the internal port 3000.
95
- // Use portBindings from inspect (works stopped) with fallback to c.Ports (running only).
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
- const publicPort = port3000Binding?.[0]?.HostPort
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
- traefikUrl,
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.