@flame0510/project-aether 1.1.13 → 1.1.14

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.
@@ -95,6 +95,77 @@ export function CodeSkeleton({ lines = 12 }: { lines?: number }) {
95
95
  );
96
96
  }
97
97
 
98
+ /**
99
+ * Cron job rows — mirrors the real row in the scheduled-jobs column so the
100
+ * layout doesn't jump when the data lands: same 12px/14px padding, same divider,
101
+ * same title/description/badge header, schedule line, and NEXT RUN / LAST RUN
102
+ * grid. Widths vary per row so it reads as a list rather than a stack of bars.
103
+ */
104
+ export function CronJobsSkeleton({ rows = 5 }: { rows?: number }) {
105
+ const shapes = [
106
+ { title: '58%', desc: '82%', badge: 54, expr: '34%' },
107
+ { title: '42%', desc: null, badge: 40, expr: '28%' },
108
+ { title: '66%', desc: '71%', badge: 62, expr: '40%' },
109
+ { title: '50%', desc: null, badge: 46, expr: '31%' },
110
+ { title: '61%', desc: '64%', badge: 58, expr: '36%' },
111
+ { title: '45%', desc: null, badge: 50, expr: '26%' },
112
+ ];
113
+
114
+ return (
115
+ <div aria-hidden="true">
116
+ {shapes.slice(0, rows).map((s, i) => (
117
+ <div key={i} style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
118
+ {/* Header: title + optional description on the left, agent pill right */}
119
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, gap: 8 }}>
120
+ <div style={{ flex: 1, minWidth: 0 }}>
121
+ <Skeleton width={s.title} height={12} accent />
122
+ {s.desc && <Skeleton width={s.desc} height={9} style={{ marginTop: 4 }} />}
123
+ </div>
124
+ <Skeleton width={s.badge} height={13} style={{ flexShrink: 0 }} />
125
+ </div>
126
+
127
+ {/* Schedule line: label + cron expression */}
128
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
129
+ <Skeleton width={46} height={9} />
130
+ <Skeleton width={s.expr} height={10} />
131
+ </div>
132
+
133
+ {/* NEXT RUN / LAST RUN grid */}
134
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px' }}>
135
+ {[0, 1].map((c) => (
136
+ <div key={c}>
137
+ <Skeleton width={52} height={8} style={{ marginBottom: 3 }} />
138
+ <Skeleton width="72%" height={10} />
139
+ </div>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Cron run-history entries — one timestamp line plus a status/duration row,
150
+ * matching the entries the runs column renders once a job is selected.
151
+ */
152
+ export function CronRunsSkeleton({ rows = 6 }: { rows?: number }) {
153
+ const widths = ['64%', '48%', '71%', '55%', '60%', '44%'];
154
+ return (
155
+ <div aria-hidden="true">
156
+ {widths.slice(0, rows).map((w, i) => (
157
+ <div key={i} style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
158
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 5 }}>
159
+ <Skeleton width={w} height={10} />
160
+ <Skeleton width={38} height={11} style={{ flexShrink: 0 }} />
161
+ </div>
162
+ <Skeleton width="30%" height={9} />
163
+ </div>
164
+ ))}
165
+ </div>
166
+ );
167
+ }
168
+
98
169
  /* ─── Legacy aliases (used by existing components) ────────────────────────── */
99
170
  /** Stack of horizontal skeleton lines */
100
171
  export function SkeletonLines({ count = 3, width }: { count?: number; width?: string | number }) {
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useState, useRef } from 'react';
4
4
  import { useResponsive } from '../design-system';
5
5
  import { Button, Pill } from '../components/ui';
6
- import { SkeletonLines } from '../components/Skeleton';
6
+ import { CronJobsSkeleton, CronRunsSkeleton } from '../components/Skeleton';
7
7
  import { apiFetch } from '@/lib/apiFetch';
8
8
  import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
9
9
  import { formatDateTimeInTimezone } from '@/lib/timezone';
@@ -266,9 +266,28 @@ export default function CronsPage() {
266
266
 
267
267
  useEffect(() => {
268
268
  const controller = new AbortController();
269
- void load(controller.signal);
270
- const t = setInterval(() => { if (!controller.signal.aborted) void load(controller.signal); }, 15_000);
271
- return () => { controller.abort(); clearInterval(t); };
269
+ let timer: ReturnType<typeof setTimeout> | undefined;
270
+
271
+ // Chained setTimeout rather than setInterval, deliberately.
272
+ //
273
+ // GET /api/crons runs `openclaw cron list --json` inside every agent
274
+ // container. That command takes ~5s per agent and the route walks them in
275
+ // sequence, so the response time scales with the number of agents and is
276
+ // already well past 15s on a multi-agent host. setInterval fires on schedule
277
+ // whether or not the previous call has come back, so those requests pile up
278
+ // — and since the route blocks the server's event loop while it runs, the
279
+ // whole dashboard stops answering for as long as this page stays open.
280
+ //
281
+ // Scheduling the next run only once the current one has settled turns the
282
+ // delay into a gap between polls instead of a deadline, so the loop can
283
+ // never outrun the endpoint no matter how slow it gets.
284
+ const poll = async () => {
285
+ await load(controller.signal);
286
+ if (!controller.signal.aborted) timer = setTimeout(poll, 15_000);
287
+ };
288
+ void poll();
289
+
290
+ return () => { controller.abort(); if (timer) clearTimeout(timer); };
272
291
  }, []);
273
292
 
274
293
  return (
@@ -278,7 +297,9 @@ export default function CronsPage() {
278
297
  <span className="crons-header__meta">{runs.length} runs loaded</span>
279
298
  </div>
280
299
 
281
- {loading && <SkeletonLines count={4} />}
300
+ {/* No loader here: the skeleton lives inside each column, so the page keeps
301
+ its real layout while loading instead of showing a stack of bars above
302
+ two empty panels. */}
282
303
 
283
304
  {/* Agent filter bar */}
284
305
  {agentFilters.length > 1 && (
@@ -307,9 +328,12 @@ export default function CronsPage() {
307
328
  {/* Scheduled jobs */}
308
329
  <section style={{ width: isMobile ? '100%' : '40%', borderRight: isMobile ? 'none' : '1px solid var(--border)', display: isMobile && tab !== 'jobs' ? 'none' : 'flex', flexDirection: 'column', minHeight: 0 }}>
309
330
  <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: '#555', flexShrink: 0 }}>
310
- SCHEDULED JOBS ({filteredJobs.length})
331
+ {/* Hide the count on the cold load — "(0)" beside a skeleton reads as
332
+ "no jobs", which is precisely what isn't known yet. */}
333
+ SCHEDULED JOBS{loading && filteredJobs.length === 0 ? '' : ` (${filteredJobs.length})`}
311
334
  </div>
312
335
  <div style={{ flex: 1, overflowY: 'auto' }}>
336
+ {loading && filteredJobs.length === 0 && <CronJobsSkeleton />}
313
337
  {filteredJobs.length === 0 && !loading && (
314
338
  <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>No scheduled jobs found</div>
315
339
  )}
@@ -471,10 +495,11 @@ export default function CronsPage() {
471
495
  RUN HISTORY {selectedJob ? `— ${selectedJob.name ?? selectedJob.id?.slice(0, 8) ?? ''}` : ''}
472
496
  </div>
473
497
  <div style={{ flex: 1, overflowY: 'auto' }}>
498
+ {loading && !selectedJob && <CronRunsSkeleton />}
474
499
  {!selectedJob && !loading && (
475
500
  <div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>Select a cron job to view its run history</div>
476
501
  )}
477
- {selectedJob && runsLoading && <SkeletonLines count={3} />}
502
+ {selectedJob && runsLoading && <CronRunsSkeleton />}
478
503
  {selectedJob && !runsLoading && runsError && (
479
504
  <div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--red)' }}>{runsError}</div>
480
505
  )}
@@ -147,7 +147,6 @@ and guaranteed at runtime by `applyRuntimeConfig()`.
147
147
  AGENT_NAME=<agent-name>
148
148
  AGENT_TEMPLATE=<template>
149
149
  OPENCLAW_GATEWAY_TOKEN=<shared agent token>
150
- AGENT_HOSTNAME=<host-ip>:<portStart>
151
150
  TZ=Europe/Rome
152
151
  ```
153
152
 
@@ -548,9 +548,9 @@ List running agent containers (Docker containers with `AGENT_ID` label) with ful
548
548
  "ports": "",
549
549
  "ip": "172.19.0.5",
550
550
  "created": "2026-07-01T16:58:42.876829416Z",
551
- "env": ["AGENT_ID=prometheus", "AGENT_HOSTNAME=<agent-name>.<your-domain>.com"],
551
+ "env": ["AGENT_ID=prometheus", "AGENT_TEMPLATE=prometheus"],
552
552
  "authToken": "***",
553
- "traefikUrl": "http://187.77.156.41:3033#token=asdfghjkl"
553
+ "controlPort": "3033"
554
554
  }
555
555
  ]
556
556
 
@@ -589,7 +589,7 @@ Create a new agent container from a template.
589
589
  **Every agent is created with a host port mapping:**
590
590
  - Port block: `-p <start>-<end>:3000-<3000+offset>` — maps a range of host ports to the same range starting at container port 3000
591
591
  - Single port: `-p <port>:3000` — maps a single host port to container port 3000
592
- - URL format: `http://<host-ip>:<portStart>#token=...`
592
+ - Control UI URL: composed by the browser as `http://<window.location.hostname>:<port>#token=<controlToken>` — the server returns the parts, not the URL
593
593
  - Port 3740 is reserved for Rev4a
594
594
 
595
595
  **Response:**
@@ -600,7 +600,7 @@ Create a new agent container from a template.
600
600
  "name": "my-agent",
601
601
  "image": "openclaw-agent-base:latest",
602
602
  "network": "rev4a-network",
603
- "traefikUrl": "http://187.77.156.41:3700#token=asdfghjkl",
603
+ "controlToken": "asdfghjkl",
604
604
  "port": 3700,
605
605
  "portEnd": 3709,
606
606
  "isBlock": true,
@@ -97,6 +97,28 @@ function findContainerById(agentId: string): string | null {
97
97
 
98
98
  // ── Public API ───────────────────────────────
99
99
 
100
+ /**
101
+ * Read the Telegram channel out of an already-parsed openclaw.json.
102
+ *
103
+ * Pure — no container access — so callers that have obtained the config some
104
+ * other way (in bulk, asynchronously) can share the same interpretation instead
105
+ * of reimplementing it.
106
+ */
107
+ export function parseTelegramChannel(config: Record<string, unknown>): TelegramChannel | null {
108
+ const telegram = (config.channels as Record<string, unknown>)?.telegram as Record<string, unknown> | undefined;
109
+ if (!telegram || telegram.enabled === false) return null;
110
+
111
+ const botToken = (telegram.botToken ?? '') as string;
112
+ if (typeof botToken !== 'string' || botToken.length === 0) return null;
113
+
114
+ return {
115
+ connected: true,
116
+ botToken: maskToken(botToken),
117
+ dmPolicy: (telegram.dmPolicy ?? 'pairing') as string,
118
+ allowFrom: (Array.isArray(telegram.allowFrom) ? telegram.allowFrom : []) as string[],
119
+ };
120
+ }
121
+
100
122
  export function getChannels(agentId: string): AgentChannels {
101
123
  const container = findContainerById(agentId);
102
124
  const result: AgentChannels = { agentId, telegram: null };
@@ -106,22 +128,7 @@ export function getChannels(agentId: string): AgentChannels {
106
128
  const config = readConfigJson(container);
107
129
  if (!config) return result;
108
130
 
109
- // ── Telegram ──
110
- const telegram = (config.channels as Record<string, unknown>)?.telegram as Record<string, unknown> | undefined;
111
- if (telegram && telegram.enabled !== false) {
112
- const botToken = (telegram.botToken ?? '') as string;
113
- const hasToken = typeof botToken === 'string' && botToken.length > 0;
114
-
115
- if (hasToken) {
116
- result.telegram = {
117
- connected: true,
118
- botToken: maskToken(botToken),
119
- dmPolicy: (telegram.dmPolicy ?? 'pairing') as string,
120
- allowFrom: (Array.isArray(telegram.allowFrom) ? telegram.allowFrom : []) as string[],
121
- };
122
- }
123
- }
124
-
131
+ result.telegram = parseTelegramChannel(config);
125
132
  return result;
126
133
  }
127
134
 
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Running commands inside agent containers, without blocking the server.
3
+ *
4
+ * Every route that needed something from a container used to call `execSync` or
5
+ * `execFileSync` directly. Those are synchronous: while one runs, Node's single
6
+ * thread does nothing else — no other request is served, no agent's stream
7
+ * advances. Node's own guidance names `execSync` in a server as a
8
+ * denial-of-service vector, and the numbers here bear that out: a bare
9
+ * `docker exec … echo` costs ~260ms, and the OpenClaw CLI inside a container
10
+ * takes 2.5–6s per invocation because it boots a whole Node process.
11
+ *
12
+ * Routes that walk every agent multiplied that: `/api/crons` measured 19.2s on a
13
+ * seven-agent host, all of it with the event loop frozen.
14
+ *
15
+ * Everything here is async. The commands are still as slow as they were — that
16
+ * cost belongs to the OpenClaw CLI — but they no longer stop the rest of the
17
+ * server while they run.
18
+ */
19
+ import { execFile, spawn } from 'child_process';
20
+ import { promisify } from 'util';
21
+ import { resolveDockerSocket } from '@/lib/docker-socket';
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ /** Commands are expected to answer quickly; the OpenClaw CLI needs the headroom. */
26
+ const DEFAULT_TIMEOUT_MS = 15_000;
27
+
28
+ /** Matches what the previous callers passed, and covers `openclaw models list --json`. */
29
+ const DEFAULT_MAX_BUFFER = 1024 * 1024;
30
+
31
+ export interface DockerExecOptions {
32
+ timeoutMs?: number;
33
+ /** Extra environment for the command, passed as `-e KEY=VALUE`. */
34
+ env?: Record<string, string>;
35
+ maxBuffer?: number;
36
+ signal?: AbortSignal;
37
+ }
38
+
39
+ function baseArgs(container: string, opts?: DockerExecOptions): string[] {
40
+ const args = ['exec'];
41
+ for (const [k, v] of Object.entries(opts?.env ?? {})) args.push('-e', `${k}=${v}`);
42
+ args.push(container);
43
+ return args;
44
+ }
45
+
46
+ /**
47
+ * Run a command inside a container, passing argv directly.
48
+ *
49
+ * Prefer this over {@link dockerExecShell}: with no shell in between there is
50
+ * nothing to quote and nothing to escape, so a filename with a space or a quote
51
+ * cannot turn into a second command.
52
+ *
53
+ * Rejects when the command fails, the container is missing, or the timeout hits.
54
+ */
55
+ export async function dockerExec(
56
+ container: string,
57
+ argv: string[],
58
+ opts?: DockerExecOptions,
59
+ ): Promise<string> {
60
+ if (!resolveDockerSocket()) throw new Error('Docker is not available');
61
+
62
+ const { stdout } = await execFileAsync(
63
+ 'docker',
64
+ [...baseArgs(container, opts), ...argv],
65
+ {
66
+ encoding: 'utf-8',
67
+ timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
68
+ maxBuffer: opts?.maxBuffer ?? DEFAULT_MAX_BUFFER,
69
+ signal: opts?.signal,
70
+ },
71
+ );
72
+ return stdout;
73
+ }
74
+
75
+ /**
76
+ * Run a shell command inside a container. Only for commands that genuinely need
77
+ * a shell — pipes, redirection, globbing. Anything interpolated into `cmd` is
78
+ * shell syntax, so callers must quote it themselves.
79
+ */
80
+ export async function dockerExecShell(
81
+ container: string,
82
+ cmd: string,
83
+ opts?: DockerExecOptions,
84
+ ): Promise<string> {
85
+ return dockerExec(container, ['sh', '-c', cmd], opts);
86
+ }
87
+
88
+ /**
89
+ * As {@link dockerExec}, but resolves to '' instead of rejecting.
90
+ *
91
+ * This is the shape most of the existing callers want: a container that is
92
+ * stopped, or an OpenClaw subcommand that isn't available on that agent, is a
93
+ * normal condition here — not something to fail a whole page over.
94
+ */
95
+ export async function dockerExecNoFail(
96
+ container: string,
97
+ argv: string[],
98
+ opts?: DockerExecOptions,
99
+ ): Promise<string> {
100
+ try {
101
+ return await dockerExec(container, argv, opts);
102
+ } catch {
103
+ return '';
104
+ }
105
+ }
106
+
107
+ /** Shell variant of {@link dockerExecNoFail}. */
108
+ export async function dockerExecShellNoFail(
109
+ container: string,
110
+ cmd: string,
111
+ opts?: DockerExecOptions,
112
+ ): Promise<string> {
113
+ return dockerExecNoFail(container, ['sh', '-c', cmd], opts);
114
+ }
115
+
116
+ /**
117
+ * Run a command inside a container and write `input` to its stdin.
118
+ *
119
+ * This uses `spawn` rather than `execFile` on purpose: `promisify(exec)` and
120
+ * `promisify(execFile)` silently ignore an `input` option — the process starts,
121
+ * stdin is never written, and the command hangs or reads nothing, with no error
122
+ * to point at. `openclaw config patch --stdin` is the case that needs this.
123
+ */
124
+ export function dockerExecWithInput(
125
+ container: string,
126
+ argv: string[],
127
+ input: string,
128
+ opts?: DockerExecOptions,
129
+ ): Promise<string> {
130
+ if (!resolveDockerSocket()) return Promise.reject(new Error('Docker is not available'));
131
+
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawn('docker', ['exec', '-i', ...baseArgs(container, opts).slice(1), ...argv], {
134
+ stdio: ['pipe', 'pipe', 'pipe'],
135
+ });
136
+
137
+ let stdout = '';
138
+ let stderr = '';
139
+ const timer = setTimeout(() => {
140
+ child.kill('SIGKILL');
141
+ reject(new Error(`docker exec timed out after ${opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
142
+ }, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
143
+
144
+ child.stdout.on('data', (c) => { stdout += c; });
145
+ child.stderr.on('data', (c) => { stderr += c; });
146
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
147
+ child.on('close', (code) => {
148
+ clearTimeout(timer);
149
+ if (code === 0) resolve(stdout);
150
+ else reject(new Error(stderr.trim() || `docker exec exited with code ${code}`));
151
+ });
152
+
153
+ child.stdin.on('error', () => { /* the close handler reports the real failure */ });
154
+ child.stdin.end(input);
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Map over items with a cap on how many run at once.
160
+ *
161
+ * The point of making these calls async is to run an agent fleet's worth of them
162
+ * concurrently instead of one after another. Doing that with a bare
163
+ * `Promise.all` would spawn one `docker exec` per agent simultaneously, which is
164
+ * fine for seven agents and not fine for seventy — each one is a process, and
165
+ * the Docker daemon serialises past a point anyway. The cap keeps the win
166
+ * without trading a frozen event loop for a swamped daemon.
167
+ *
168
+ * Results keep the order of `items`, regardless of completion order.
169
+ *
170
+ * Fail-fast, like `Promise.all`: if `fn` rejects for one item the whole call
171
+ * rejects. That is deliberate — silently swallowing failures here would hide
172
+ * real errors — but it means callers fanning out over an agent fleet should pass
173
+ * a function that handles its own failures (the `…NoFail` variants above, or an
174
+ * explicit try/catch), so one unreachable container can't blank an entire page.
175
+ */
176
+ export async function mapWithConcurrency<T, R>(
177
+ items: readonly T[],
178
+ limit: number,
179
+ fn: (item: T, index: number) => Promise<R>,
180
+ ): Promise<R[]> {
181
+ if (items.length === 0) return [];
182
+ const results = new Array<R>(items.length);
183
+ let next = 0;
184
+
185
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
186
+ while (true) {
187
+ const i = next++;
188
+ if (i >= items.length) return;
189
+ results[i] = await fn(items[i], i);
190
+ }
191
+ });
192
+
193
+ await Promise.all(workers);
194
+ return results;
195
+ }
@@ -1,54 +1,28 @@
1
- import { networkInterfaces } from 'os';
2
-
3
1
  /**
4
- * Detect the host's best public-facing IPv4 address for agent URLs.
2
+ * Docker-related host helpers.
3
+ *
4
+ * This module used to export `detectPublicHost()`, which shelled out to
5
+ * `curl icanhazip.com` via execSync to discover the machine's public IP, then
6
+ * fell back to `hostname -I` and finally the literal string 'localhost'. It was
7
+ * removed rather than optimised, because the caching it seemed to need would
8
+ * only have made a wrong answer stable:
5
9
  *
6
- * Priority:
7
- * 1. External IP detection via icanhazip.com (works from Docker containers)
8
- * 2. Hostname resolution (filters out 172.x Docker internal IPs)
9
- * 3. First non-internal IPv4 network interface
10
- * 4. 'localhost' fallback
10
+ * - execSync blocks the entire event loop, so every call froze the whole
11
+ * server — not just its own request — for ~270ms, and up to the 5s timeout
12
+ * when the network hung. Node's own guidance names execSync in a server as
13
+ * a denial-of-service vector.
14
+ * - On macOS it never even worked: icanhazip answers with IPv6, which the
15
+ * IPv4-only validation discarded, and `hostname -I` is a GNU-only flag. The
16
+ * value that came back was the machine's `.local` mDNS name, reachable only
17
+ * from the same LAN.
18
+ * - It answered the wrong question. Callers needed "which URL should this
19
+ * user's browser open", not "what is this machine's WAN address". Those
20
+ * differ behind NAT, behind a reverse proxy, and over a tunnel.
21
+ *
22
+ * Agent URLs are now composed in the browser from the port returned by the API
23
+ * plus `window.location.hostname` — a hostname already proven to reach this
24
+ * server, since the dashboard is being served over it.
11
25
  */
12
- export function detectPublicHost(): string {
13
- // 1. External detection (works from inside Docker containers)
14
- try {
15
- const { execSync } = require('child_process');
16
- const ip = execSync(
17
- 'curl -fs --max-time 3 icanhazip.com 2>/dev/null || curl -fs --max-time 3 ifconfig.me 2>/dev/null',
18
- { encoding: 'utf-8', timeout: 5000 },
19
- ).trim();
20
- if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !ip.startsWith('172.')) {
21
- return ip;
22
- }
23
- } catch { /* ignore */ }
24
-
25
- // 2. Hostname (often gives public IP on VPS, but Docker internal 172.x is filtered)
26
- try {
27
- const { execSync } = require('child_process');
28
- const hostname = execSync('hostname -I 2>/dev/null || hostname 2>/dev/null', {
29
- encoding: 'utf-8', timeout: 2000,
30
- }).trim().split(/\s+/)[0];
31
- if (hostname && hostname !== '127.0.0.1' && hostname !== '::1' && !hostname.startsWith('172.')) {
32
- return hostname;
33
- }
34
- } catch { /* ignore */ }
35
-
36
- // 3. Network interfaces (bare metal)
37
- try {
38
- const ifaces = networkInterfaces();
39
- for (const [, addrs] of Object.entries(ifaces)) {
40
- if (!addrs) continue;
41
- for (const addr of addrs) {
42
- if (addr.family === 'IPv4' && !addr.internal) {
43
- return addr.address;
44
- }
45
- }
46
- }
47
- } catch { /* fall through */ }
48
-
49
- // 4. Fallback
50
- return 'localhost';
51
- }
52
26
 
53
27
  /**
54
28
  * Build the provider gateway base URL used to sync model config into agent