@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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. package/app/api/gateway/route.ts +0 -191
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Agent base images: the OpenClaw versions downloaded here, the agents using each,
3
+ * the supported versions the registry publishes, and pulling one.
4
+ *
5
+ * Images are addressed by version tag, `openclaw-agent-base:<version>`, never by
6
+ * `:latest`. An agent's OpenClaw version decides its data schema, and a moving shared
7
+ * tag would move every new or recreated agent with it. Create uses the newest
8
+ * supported version downloaded; recreate keeps the agent's own version.
9
+ */
10
+ import { spawn } from 'child_process';
11
+ import { dockerFetch } from '@/lib/docker-socket';
12
+ import { dockerExecNoFail } from '@/lib/docker-exec';
13
+ import {
14
+ LOCAL_IMAGE_REPOSITORY,
15
+ compareVersions,
16
+ isSupportedVersion,
17
+ localImageRef,
18
+ parseOpenClawVersion,
19
+ registryRepository,
20
+ } from '@/lib/agent-versions';
21
+
22
+ const VERSION_LABEL = 'org.opencontainers.image.version';
23
+ const REMOTE_TTL_MS = 10 * 60 * 1000;
24
+ const REGISTRY_TIMEOUT_MS = 10_000;
25
+ const PULL_TIMEOUT_MS = 30 * 60 * 1000;
26
+
27
+ export interface LocalAgentImage {
28
+ id: string;
29
+ tags: string[];
30
+ /** From the version label, else from a version tag; null for an unlabelled, untagged image. */
31
+ version: string | null;
32
+ sizeBytes: number;
33
+ /** AGENT_ID of every container, running or stopped, created from this image. */
34
+ usedBy: string[];
35
+ }
36
+
37
+ interface DockerImage {
38
+ Id: string;
39
+ RepoTags?: string[] | null;
40
+ Labels?: Record<string, string> | null;
41
+ Size?: number;
42
+ }
43
+
44
+ interface DockerContainer {
45
+ ImageID?: string;
46
+ Labels?: Record<string, string> | null;
47
+ }
48
+
49
+ const tagPrefix = `${LOCAL_IMAGE_REPOSITORY}:`;
50
+
51
+ function versionFromTags(tags: string[]): string | null {
52
+ for (const tag of tags) {
53
+ if (!tag.startsWith(tagPrefix)) continue;
54
+ const version = parseOpenClawVersion(tag.slice(tagPrefix.length));
55
+ if (version) return version;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** Local `openclaw-agent-base` images, through the Docker API. */
61
+ export async function listLocalAgentImages(): Promise<LocalAgentImage[]> {
62
+ const imageFilter = encodeURIComponent(JSON.stringify({ reference: [LOCAL_IMAGE_REPOSITORY] }));
63
+ const agentFilter = encodeURIComponent(JSON.stringify({ label: ['AGENT_ID'] }));
64
+ const [images, containers] = await Promise.all([
65
+ dockerFetch<DockerImage[]>('GET', `/images/json?filters=${imageFilter}`),
66
+ dockerFetch<DockerContainer[]>('GET', `/containers/json?all=true&filters=${agentFilter}`),
67
+ ]);
68
+
69
+ const usedBy = new Map<string, string[]>();
70
+ for (const c of Array.isArray(containers) ? containers : []) {
71
+ const agentId = c.Labels?.AGENT_ID;
72
+ if (!c.ImageID || !agentId) continue;
73
+ usedBy.set(c.ImageID, [...(usedBy.get(c.ImageID) ?? []), agentId]);
74
+ }
75
+
76
+ return (Array.isArray(images) ? images : []).map((img) => {
77
+ const tags = (img.RepoTags ?? []).filter((t) => t !== '<none>:<none>');
78
+ return {
79
+ id: img.Id,
80
+ tags,
81
+ version: parseOpenClawVersion(img.Labels?.[VERSION_LABEL]) ?? versionFromTags(tags),
82
+ sizeBytes: img.Size ?? 0,
83
+ usedBy: usedBy.get(img.Id) ?? [],
84
+ };
85
+ });
86
+ }
87
+
88
+ /** Supported versions present locally under their version tag, newest first. */
89
+ export function localSupportedVersions(images: LocalAgentImage[]): string[] {
90
+ const versions = new Set<string>();
91
+ for (const img of images) {
92
+ for (const tag of img.tags) {
93
+ if (tag.startsWith(tagPrefix) && isSupportedVersion(tag.slice(tagPrefix.length))) {
94
+ versions.add(tag.slice(tagPrefix.length));
95
+ }
96
+ }
97
+ }
98
+ return [...versions].sort((a, b) => compareVersions(b, a));
99
+ }
100
+
101
+ export const newestLocalSupportedVersion = (images: LocalAgentImage[]): string | null =>
102
+ localSupportedVersions(images)[0] ?? null;
103
+
104
+ /**
105
+ * Whether an image reference (a tag or an id) exists locally. `dockerFetch` resolves
106
+ * Docker's error bodies too, so a 404 is told apart by the missing `Id`.
107
+ */
108
+ async function imageExists(reference: string): Promise<boolean> {
109
+ try {
110
+ const info = await dockerFetch<{ Id?: unknown }>('GET', `/images/${encodeURIComponent(reference)}/json`);
111
+ return typeof info?.Id === 'string';
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /** Whether `openclaw-agent-base:<version>` exists locally. */
118
+ export const localVersionExists = (version: string): Promise<boolean> => imageExists(localImageRef(version));
119
+
120
+ /**
121
+ * Versions read from inside containers, keyed by image id: an image never changes
122
+ * version. A failed read is remembered for a few minutes too, so a container whose
123
+ * image is not OpenClaw does not cost a `docker exec` on every agent list poll.
124
+ */
125
+ const versionByImageId = new Map<string, { version: string | null; at: number }>();
126
+ const UNKNOWN_VERSION_TTL_MS = 5 * 60 * 1000;
127
+
128
+ /**
129
+ * The OpenClaw version a container runs: its image's label or version tag, else
130
+ * `openclaw --version` inside the running container, for images built before the
131
+ * label existed (2026.7.1-2 published as `:latest` only). Null for a stopped container
132
+ * whose image carries neither.
133
+ */
134
+ export async function containerOpenClawVersion(
135
+ container: { name: string; imageId: string; running: boolean },
136
+ images: LocalAgentImage[],
137
+ ): Promise<string | null> {
138
+ const fromImage = images.find((img) => img.id === container.imageId)?.version;
139
+ if (fromImage) return fromImage;
140
+ const cached = versionByImageId.get(container.imageId);
141
+ if (cached && (cached.version || Date.now() - cached.at < UNKNOWN_VERSION_TTL_MS)) return cached.version;
142
+ if (!container.running) return null;
143
+ const version = parseOpenClawVersion(
144
+ await dockerExecNoFail(container.name, ['openclaw', '--version'], { timeoutMs: 30_000 }),
145
+ );
146
+ versionByImageId.set(container.imageId, { version, at: Date.now() });
147
+ return version;
148
+ }
149
+
150
+ // ── Registry ─────────────────────────────────────────────────────────────────
151
+
152
+ function splitRepository(repository: string): { host: string; path: string } {
153
+ const [first, ...rest] = repository.split('/');
154
+ if (rest.length && (first.includes('.') || first.includes(':') || first === 'localhost')) {
155
+ return { host: first, path: rest.join('/') };
156
+ }
157
+ return { host: 'registry-1.docker.io', path: repository.includes('/') ? repository : `library/${repository}` };
158
+ }
159
+
160
+ const isLocalRegistryHost = (host: string) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
161
+
162
+ /**
163
+ * Tags of a repository through the registry HTTP API. A public registry such as ghcr
164
+ * answers 401 with a Bearer challenge; an anonymous token for `pull` is then fetched
165
+ * from the realm it names. A registry on localhost is plain HTTP.
166
+ */
167
+ async function fetchRegistryTags(repository: string): Promise<string[]> {
168
+ const { host, path } = splitRepository(repository);
169
+ const url = `${isLocalRegistryHost(host) ? 'http' : 'https'}://${host}/v2/${path}/tags/list?n=1000`;
170
+ const get = (target: string | URL, headers?: Record<string, string>) =>
171
+ fetch(target, { headers, cache: 'no-store', signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) });
172
+
173
+ let res = await get(url);
174
+ if (res.status === 401) {
175
+ const challenge = res.headers.get('www-authenticate') ?? '';
176
+ const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
177
+ if (!params.realm) throw new Error('the registry asked for credentials');
178
+ const tokenUrl = new URL(params.realm);
179
+ if (params.service) tokenUrl.searchParams.set('service', params.service);
180
+ tokenUrl.searchParams.set('scope', params.scope ?? `repository:${path}:pull`);
181
+ const tokenRes = await get(tokenUrl);
182
+ if (!tokenRes.ok) throw new Error(`registry token request failed (HTTP ${tokenRes.status})`);
183
+ const body = (await tokenRes.json()) as { token?: string; access_token?: string };
184
+ const token = body.token ?? body.access_token;
185
+ if (!token) throw new Error('the registry returned no token');
186
+ res = await get(url, { Authorization: `Bearer ${token}` });
187
+ }
188
+ if (!res.ok) throw new Error(`registry tag list failed (HTTP ${res.status})`);
189
+ return ((await res.json()) as { tags?: string[] | null }).tags ?? [];
190
+ }
191
+
192
+ let remoteCache: { repository: string; versions: string[] | null; at: number } | null = null;
193
+ let remoteInFlight: Promise<string[] | null> | null = null;
194
+
195
+ /**
196
+ * Supported versions the registry publishes, newest first; null when it cannot be
197
+ * reached. Cached for ten minutes, and concurrent callers share one lookup, so a
198
+ * banner polling every two seconds costs one request per TTL.
199
+ */
200
+ export async function remoteSupportedVersions(): Promise<string[] | null> {
201
+ const repository = registryRepository();
202
+ if (remoteCache && remoteCache.repository === repository && Date.now() - remoteCache.at < REMOTE_TTL_MS) {
203
+ return remoteCache.versions;
204
+ }
205
+ if (remoteInFlight) return remoteInFlight;
206
+
207
+ remoteInFlight = (async () => {
208
+ try {
209
+ const tags = await fetchRegistryTags(repository);
210
+ return tags.filter(isSupportedVersion).sort((a, b) => compareVersions(b, a));
211
+ } catch {
212
+ return null;
213
+ }
214
+ })();
215
+ try {
216
+ const versions = await remoteInFlight;
217
+ remoteCache = { repository, versions, at: Date.now() };
218
+ return versions;
219
+ } finally {
220
+ remoteInFlight = null;
221
+ }
222
+ }
223
+
224
+ // ── Pull ─────────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Runs `docker <args>`, streaming output lines; rejects with the last error line.
228
+ *
229
+ * `env` is for container environment on `docker run`: each name is inserted as `-e KEY`
230
+ * right after the subcommand, and the value reaches docker through its own
231
+ * environment, so secrets stay out of the process table.
232
+ */
233
+ export function runDocker(
234
+ args: string[],
235
+ opts: { onLine?: (line: string) => void; signal?: AbortSignal; timeoutMs: number; env?: Record<string, string> },
236
+ ): Promise<void> {
237
+ return new Promise((resolve, reject) => {
238
+ const envNames = Object.keys(opts.env ?? {});
239
+ const argv = envNames.length ? [args[0], ...envNames.flatMap((k) => ['-e', k]), ...args.slice(1)] : args;
240
+ const child = spawn('docker', argv, {
241
+ stdio: ['ignore', 'pipe', 'pipe'],
242
+ ...(envNames.length ? { env: { ...process.env, ...opts.env } } : {}),
243
+ });
244
+ let lastError = '';
245
+ let firstError = '';
246
+ const onData = (isErr: boolean) => (chunk: Buffer) => {
247
+ for (const line of chunk.toString().split(/\r?\n/)) {
248
+ if (!line.trim()) continue;
249
+ if (isErr) {
250
+ // Docker prints the cause first and "Run 'docker run --help' …" last; keep
251
+ // both ends so the rejection can carry the cause, not the usage pointer.
252
+ firstError ||= line.trim();
253
+ lastError = line.trim();
254
+ }
255
+ opts.onLine?.(line);
256
+ }
257
+ };
258
+ child.stdout.on('data', onData(false));
259
+ child.stderr.on('data', onData(true));
260
+
261
+ const kill = () => child.kill('SIGKILL');
262
+ const timer = setTimeout(() => {
263
+ kill();
264
+ reject(new Error(`docker ${args[0]} timed out`));
265
+ }, opts.timeoutMs);
266
+ opts.signal?.addEventListener('abort', kill, { once: true });
267
+
268
+ child.on('error', (err) => {
269
+ clearTimeout(timer);
270
+ opts.signal?.removeEventListener('abort', kill);
271
+ reject(err);
272
+ });
273
+ child.on('close', (code) => {
274
+ clearTimeout(timer);
275
+ opts.signal?.removeEventListener('abort', kill);
276
+ if (code === 0) resolve();
277
+ else reject(new Error(firstError && firstError !== lastError ? `${firstError} (${lastError})` : lastError || `docker ${args[0]} exited with code ${code}`));
278
+ });
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Pull `<registry>:<version>` and tag it `openclaw-agent-base:<version>`. The registry
284
+ * reference is removed afterwards (an untag, the layers stay), so local images list
285
+ * under the version tag only.
286
+ */
287
+ export async function pullAgentImage(
288
+ version: string,
289
+ opts: { onLine?: (line: string) => void; signal?: AbortSignal } = {},
290
+ ): Promise<void> {
291
+ if (!isSupportedVersion(version)) throw new Error(`OpenClaw ${version} is not supported by this Rev4a`);
292
+ const remote = `${registryRepository()}:${version}`;
293
+ await runDocker(['pull', remote], { ...opts, timeoutMs: PULL_TIMEOUT_MS });
294
+ await runDocker(['tag', remote, localImageRef(version)], { ...opts, timeoutMs: 30_000 });
295
+ await runDocker(['rmi', remote], { timeoutMs: 30_000 }).catch(() => {});
296
+ }
297
+
298
+ /**
299
+ * Retention: remove agent images nothing needs.
300
+ *
301
+ * Kept: every image a container uses, running or stopped, and the newest and the
302
+ * previous OpenClaw version present — the previous one is what a rollback runs. Other
303
+ * images lose all their tags. A kept image loses its extra tags (`latest`,
304
+ * `2026.9.3-local`) when it has a version tag. Nothing is forced: Docker refuses to
305
+ * remove an image a container uses, the last safety net. A removed version can be
306
+ * pulled again while the registry publishes it. Returns the references removed.
307
+ */
308
+ export async function pruneAgentImages(): Promise<string[]> {
309
+ const images = await listLocalAgentImages();
310
+ const versions = [...new Set(images.map((img) => img.version).filter((v): v is string => !!v))]
311
+ .sort((a, b) => compareVersions(b, a));
312
+ const keepVersions = new Set(versions.slice(0, 2));
313
+
314
+ const removed: string[] = [];
315
+ for (const img of images) {
316
+ const keep = img.usedBy.length > 0 || (img.version !== null && keepVersions.has(img.version));
317
+ const versionTags = img.tags.filter((t) => {
318
+ if (!t.startsWith(tagPrefix)) return false;
319
+ const tag = t.slice(tagPrefix.length);
320
+ return parseOpenClawVersion(tag) === tag;
321
+ });
322
+ const targets = keep ? (versionTags.length ? img.tags.filter((t) => !versionTags.includes(t)) : []) : img.tags;
323
+ for (const tag of targets) {
324
+ try {
325
+ await runDocker(['rmi', tag], { timeoutMs: 120_000 });
326
+ removed.push(tag);
327
+ } catch {
328
+ // In use, or already gone.
329
+ }
330
+ }
331
+ }
332
+ return removed;
333
+ }
334
+
335
+ /**
336
+ * The image a recreate uses: the agent's own OpenClaw version under its version tag.
337
+ * A recreate never changes version.
338
+ *
339
+ * When the tag is missing but the container's image is still here (an agent created
340
+ * from `:latest` before version tags), that image gets the version tag. When the image
341
+ * is gone too, a supported version is pulled. An agent whose version cannot be read
342
+ * keeps the exact image id it runs.
343
+ */
344
+ export async function resolveRecreateImage(container: {
345
+ Name?: string;
346
+ Image?: string;
347
+ Config?: { Image?: string };
348
+ State?: { Running?: boolean };
349
+ }): Promise<string> {
350
+ const name = (container.Name ?? '').replace(/^\//, '');
351
+ const imageId = container.Image ?? '';
352
+ const images = await listLocalAgentImages();
353
+ const version = await containerOpenClawVersion(
354
+ { name, imageId, running: container.State?.Running === true },
355
+ images,
356
+ );
357
+
358
+ if (version) {
359
+ const ref = localImageRef(version);
360
+ if (await localVersionExists(version)) return ref;
361
+ if (imageId && (await imageExists(imageId))) {
362
+ await runDocker(['tag', imageId, ref], { timeoutMs: 30_000 });
363
+ return ref;
364
+ }
365
+ if (isSupportedVersion(version)) {
366
+ await pullAgentImage(version);
367
+ return ref;
368
+ }
369
+ throw new Error(`The image for OpenClaw ${version} is not available locally`);
370
+ }
371
+
372
+ if (imageId && (await imageExists(imageId))) return imageId;
373
+ if (container.Config?.Image) return container.Config.Image;
374
+ throw new Error('Cannot tell which image this agent runs');
375
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses
3
+ * `child_process`, which client components cannot import.
4
+ */
5
+ import { execSync } from 'child_process';
6
+
7
+ /**
8
+ * Host ports published by any container, running or not: a stopped container keeps its
9
+ * port allocations, so `docker run -p` on them fails just the same.
10
+ *
11
+ * Parsed in JS rather than piping through grep: the old `grep -oP '\d+(?=->)'` form lost
12
+ * its backslash inside a template literal, matched nothing, and offered port 3700 to
13
+ * every agent; `grep -P` is also GNU-only and absent on macOS.
14
+ */
15
+ export function getUsedHostPorts(): Set<number> {
16
+ try {
17
+ const raw = execSync(`docker ps -a --format '{{.Ports}}'`, { encoding: 'utf-8', timeout: 3000 });
18
+ const used = new Set<number>();
19
+ for (const [, port] of raw.matchAll(/(\d+)->/g)) {
20
+ const n = Number(port);
21
+ if (Number.isInteger(n)) used.add(n);
22
+ }
23
+ return used;
24
+ } catch {
25
+ return new Set();
26
+ }
27
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Host-port helpers shared by agent creation and the edit (rename/ports) flow, so both
3
+ * validate a range the same way and produce the same error messages. Pure functions only:
4
+ * this module is imported by client components too. The Docker lookup lives in
5
+ * `lib/agent-ports-server.ts`.
6
+ *
7
+ * The mapping convention is one block starting at the container's gateway port 3000:
8
+ * `3700` publishes `3700:3000`, and `3700-3709` publishes `3700-3709:3000-3009`.
9
+ * OpenClaw shifts its derived ports (browser, canvas) from 3000, which is why agents are
10
+ * created with a block rather than a single mapping.
11
+ */
12
+
13
+ /** Rev4a's own port: never offer it to an agent. */
14
+ export const REV4A_PORT = 3740;
15
+ /** Agents publish this many consecutive host ports by default. */
16
+ export const DEFAULT_BLOCK_SIZE = 10;
17
+
18
+ /** An available host block, from 3700 up, skipping Rev4a's port. */
19
+ export function findAvailablePortBlock(usedPorts: ReadonlySet<number> = new Set(), blockSize = DEFAULT_BLOCK_SIZE): number {
20
+ for (let base = 3700; base + blockSize - 1 <= 3799; base += DEFAULT_BLOCK_SIZE) {
21
+ if (base <= REV4A_PORT && REV4A_PORT <= base + blockSize - 1) continue;
22
+ const block = Array.from({ length: blockSize }, (_, i) => base + i);
23
+ if (block.every((p) => !usedPorts.has(p))) return base;
24
+ }
25
+ let base = 3800;
26
+ while (true) {
27
+ const block = Array.from({ length: blockSize }, (_, i) => base + i);
28
+ if (block.every((p) => !usedPorts.has(p))) return base;
29
+ base += DEFAULT_BLOCK_SIZE;
30
+ }
31
+ }
32
+
33
+ export type PortInputResult =
34
+ | { valid: true; isBlock: boolean; start: number; end: number }
35
+ | { valid: false; error: string };
36
+
37
+ /**
38
+ * Validate `"3700"` or `"3700-3709"` against the ports already published (pass
39
+ * `getUsedHostPorts()` on the server; the client passes what it was given).
40
+ */
41
+ export function validatePortInput(input: string, usedPorts: ReadonlySet<number> = new Set()): PortInputResult {
42
+ const trimmed = input.trim();
43
+ if (trimmed.includes('-')) {
44
+ const m = trimmed.match(/^(\d{1,5})-(\d{1,5})$/);
45
+ if (!m) return { valid: false, error: 'Invalid format. Use e.g. 3700-3709' };
46
+ const start = parseInt(m[1], 10);
47
+ const end = parseInt(m[2], 10);
48
+ if (start < 1 || end > 65535) return { valid: false, error: 'Ports must be between 1 and 65535' };
49
+ if (end <= start) return { valid: false, error: 'End port must be greater than start port' };
50
+ if (start <= REV4A_PORT && REV4A_PORT <= end) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
51
+ const conflicts: number[] = [];
52
+ for (let p = start; p <= end; p++) { if (usedPorts.has(p)) conflicts.push(p); }
53
+ if (conflicts.length > 0) return { valid: false, error: `Ports already in use: ${conflicts.join(', ')}` };
54
+ return { valid: true, isBlock: true, start, end };
55
+ }
56
+ const m = trimmed.match(/^(\d{1,5})$/);
57
+ if (!m) return { valid: false, error: 'Use a number (e.g. 3700) or a range (e.g. 3700-3709)' };
58
+ const port = parseInt(m[1], 10);
59
+ if (port < 1 || port > 65535) return { valid: false, error: 'Port must be between 1 and 65535' };
60
+ if (port === REV4A_PORT) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
61
+ if (usedPorts.has(port)) return { valid: false, error: `Port ${port} is already in use` };
62
+ return { valid: true, isBlock: false, start: port, end: port };
63
+ }
64
+
65
+ /** `docker run` port arguments for a validated range, mapping onto the container 3000. */
66
+ export function portMappingArgs(start: number, end: number): string[] {
67
+ return ['-p', end === start ? `${start}:3000` : `${start}-${end}:3000-${3000 + (end - start)}`];
68
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Waiting for an agent's Gateway to finish starting.
3
+ *
4
+ * **Which endpoint.** OpenClaw exposes three, answering three different questions:
5
+ *
6
+ * /health, /healthz the HTTP server is listening
7
+ * /startupz startup finished and the Gateway is not draining
8
+ * /readyz the above, plus configured channels pass deep checks
9
+ *
10
+ * `/startupz` is the one callers need: the create route configures the agent as
11
+ * soon as this returns. A Gateway that refused readiness — for instance one that
12
+ * found a legacy session store and wants `doctor --fix` — still answers 200 on
13
+ * `/health`. `/readyz` would fail on an expired channel token, and the create
14
+ * route deletes the container when this probe times out, so a false negative
15
+ * there destroys a healthy agent over an unrelated channel.
16
+ *
17
+ * **Two OpenClaw majors.** `/startupz` exists from 9.x: 503 while starting or
18
+ * draining, 200 with `{"status":"started"}` when done. The 2026.7.1-2 image that
19
+ * `openclaw-agent-base` currently pins has no `/startupz`; its Gateway answers
20
+ * every unknown path with 200 and the web UI's HTML. So the probe reads the
21
+ * status code *and* the body, and falls back to `/health` only for a 200 that is
22
+ * not JSON — the old image. A 503, or no answer, means "not yet", never "use
23
+ * `/health`": reading an empty answer as "old image" makes a 9.x Gateway count as
24
+ * ready the moment it starts listening.
25
+ *
26
+ * Delete the `/health` fallback once every image is on 9.x.
27
+ *
28
+ * **Async.** A request path must not hold Node's single thread for up to a
29
+ * minute; see docs/ARCHITECTURE.md §3.1.
30
+ */
31
+ import { dockerExecNoFail } from '@/lib/docker-exec';
32
+
33
+ /** Interval between attempts. The Gateway takes seconds to boot, not milliseconds. */
34
+ const POLL_INTERVAL_MS = 2_000;
35
+
36
+ /** Per-attempt budget. Generous: a loaded host can be slow to answer. */
37
+ const ATTEMPT_TIMEOUT_MS = 5_000;
38
+
39
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
40
+
41
+ /**
42
+ * One HTTP GET from inside the container, with its status code.
43
+ *
44
+ * `curl -w` appends the code as the last line. `status` is 0 when nothing usable
45
+ * came back. A deadline may reject, or resolve with truncated output because
46
+ * `docker exec` can exit 0 on the SIGTERM (docs/ARCHITECTURE.md, "A timeout does
47
+ * not reject"); either way a last line that is not a three-digit code counts as
48
+ * no answer. The image has `curl`, not `wget`.
49
+ */
50
+ async function probe(container: string, path: string): Promise<{ status: number; body: string }> {
51
+ const out = await dockerExecNoFail(
52
+ container,
53
+ ['curl', '-s', '-o', '-', '-w', '\n%{http_code}', `http://127.0.0.1:3000${path}`],
54
+ { timeoutMs: ATTEMPT_TIMEOUT_MS },
55
+ );
56
+ if (!out) return { status: 0, body: '' };
57
+ const cut = out.lastIndexOf('\n');
58
+ const tail = (cut === -1 ? out : out.slice(cut + 1)).trim();
59
+ if (!/^\d{3}$/.test(tail)) return { status: 0, body: '' };
60
+ return { status: Number(tail), body: cut === -1 ? '' : out.slice(0, cut) };
61
+ }
62
+
63
+ function asJson(body: string): Record<string, unknown> | null {
64
+ if (!body.trimStart().startsWith('{')) return null;
65
+ try {
66
+ const v = JSON.parse(body);
67
+ return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Poll a container's Gateway until it has finished starting.
75
+ *
76
+ * Resolves `true` on the first affirmative answer, `false` on timeout. Never
77
+ * throws: an unreachable container is indistinguishable from a slow one until the
78
+ * deadline passes, and every caller treats both the same way.
79
+ */
80
+ export async function waitForGatewayReady(
81
+ container: string,
82
+ timeoutMs = 60_000,
83
+ ): Promise<boolean> {
84
+ const deadline = Date.now() + timeoutMs;
85
+
86
+ while (Date.now() < deadline) {
87
+ const startupz = await probe(container, '/startupz');
88
+
89
+ if (startupz.status === 200) {
90
+ const json = asJson(startupz.body);
91
+ if (json) {
92
+ // 9.x: accept only the answer that means startup finished.
93
+ if (json.status === 'started') return true;
94
+ } else {
95
+ // 200 with a non-JSON body: the 2026.7.1-2 web UI catch-all, so this build
96
+ // has no /startupz. Liveness is the best signal it offers.
97
+ const health = await probe(container, '/health');
98
+ const h = health.status === 200 ? asJson(health.body) : null;
99
+ if (h && (h.ok === true || h.status === 'live')) return true;
100
+ }
101
+ }
102
+ // 503 (starting or draining), any other code, or no answer: not yet.
103
+
104
+ // Only sleep if there is still budget left to use afterwards.
105
+ if (Date.now() + POLL_INTERVAL_MS >= deadline) break;
106
+ await sleep(POLL_INTERVAL_MS);
107
+ }
108
+
109
+ return false;
110
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Persisted state of agent recreates: the `agent_recreates` table (lib/db-bootstrap.mjs).
3
+ *
4
+ * Kept apart from lib/agent-recreate.ts so busy checks (lib/agent-busy.ts, the provider
5
+ * sync) can ask which agents are being recreated without importing the recreate action.
6
+ */
7
+ import { openDb } from '@/lib/db';
8
+
9
+ export type RecreateStatus = 'backing_up' | 'recreating' | 'done' | 'failed' | 'interrupted';
10
+
11
+ /** Statuses during which the agent must not be touched by anything else. */
12
+ export const ACTIVE_RECREATE_STATUSES: readonly RecreateStatus[] = ['backing_up', 'recreating'];
13
+
14
+ export interface AgentRecreateRow {
15
+ id: number;
16
+ agent_id: string;
17
+ status: RecreateStatus;
18
+ image: string | null;
19
+ backup_file: string | null;
20
+ error: string | null;
21
+ started_at: number;
22
+ updated_at: number;
23
+ finished_at: number | null;
24
+ }
25
+
26
+ const FINAL: readonly RecreateStatus[] = ['done', 'failed', 'interrupted'];
27
+
28
+ export function insertRecreate(agentId: string, image: string): number {
29
+ const db = openDb(false);
30
+ try {
31
+ const now = Date.now();
32
+ const info = db.prepare(
33
+ `INSERT INTO agent_recreates (agent_id, status, image, started_at, updated_at)
34
+ VALUES (?, 'backing_up', ?, ?, ?)`,
35
+ ).run(agentId, image, now, now);
36
+ return Number(info.lastInsertRowid);
37
+ } finally {
38
+ db.close();
39
+ }
40
+ }
41
+
42
+ type Writable = Partial<Pick<AgentRecreateRow, 'status' | 'backup_file' | 'error'>>;
43
+
44
+ export function updateRecreateRow(id: number, fields: Writable): void {
45
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
46
+ const now = Date.now();
47
+ const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
48
+ const values: unknown[] = [...entries.map(([, v]) => v), now];
49
+ if (fields.status && FINAL.includes(fields.status)) {
50
+ sets.push('finished_at = ?');
51
+ values.push(now);
52
+ } else if (fields.status) {
53
+ sets.push('finished_at = NULL');
54
+ }
55
+ const db = openDb(false);
56
+ try {
57
+ db.prepare(`UPDATE agent_recreates SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ export function latestRecreate(agentId: string): AgentRecreateRow | null {
64
+ const db = openDb(true);
65
+ try {
66
+ return (db.prepare('SELECT * FROM agent_recreates WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRecreateRow | undefined) ?? null;
67
+ } finally {
68
+ db.close();
69
+ }
70
+ }
71
+
72
+ export function isRecreateActive(agentId: string): boolean {
73
+ const row = latestRecreate(agentId);
74
+ return !!row && ACTIVE_RECREATE_STATUSES.includes(row.status);
75
+ }
76
+
77
+ /** AGENT_IDs with a recreate in an active status. */
78
+ export function activeRecreateAgentIds(): Set<string> {
79
+ const db = openDb(true);
80
+ try {
81
+ const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
82
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_recreates WHERE status IN (${placeholders})`).all(...ACTIVE_RECREATE_STATUSES) as { agent_id: string }[];
83
+ return new Set(rows.map((r) => r.agent_id));
84
+ } finally {
85
+ db.close();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * At startup no recreate job can be running: any row still active was cut off by the
91
+ * restart. It becomes `interrupted`, keeping the step it was in.
92
+ */
93
+ export function markInterruptedRecreates(): number {
94
+ const db = openDb(false);
95
+ try {
96
+ const placeholders = ACTIVE_RECREATE_STATUSES.map(() => '?').join(', ');
97
+ const now = Date.now();
98
+ const info = db.prepare(
99
+ `UPDATE agent_recreates
100
+ SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
101
+ status = 'interrupted', updated_at = ?, finished_at = ?
102
+ WHERE status IN (${placeholders})`,
103
+ ).run(now, now, ...ACTIVE_RECREATE_STATUSES);
104
+ return info.changes;
105
+ } finally {
106
+ db.close();
107
+ }
108
+ }