@flame0510/project-aether 1.3.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 (76) hide show
  1. package/README.md +1 -0
  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 +4 -1
  9. package/app/agents/PageClient.tsx +629 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/api/agents/[id]/backup/route.ts +26 -69
  13. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  14. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  15. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  16. package/app/api/agents/[id]/devices/route.ts +126 -0
  17. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  18. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  19. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  20. package/app/api/agents/[id]/recreate/route.ts +33 -163
  21. package/app/api/agents/[id]/restart/route.ts +5 -0
  22. package/app/api/agents/[id]/restore/route.ts +40 -70
  23. package/app/api/agents/[id]/route.ts +38 -150
  24. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  25. package/app/api/agents/[id]/update/route.ts +50 -0
  26. package/app/api/agents/activity-summary/route.ts +67 -0
  27. package/app/api/agents/create/route.ts +32 -88
  28. package/app/api/agents/devices-summary/route.ts +37 -0
  29. package/app/api/agents/download-image/route.ts +16 -9
  30. package/app/api/agents/image-status/route.ts +31 -111
  31. package/app/api/agents/route.ts +25 -49
  32. package/app/api/agents/token/route.ts +33 -10
  33. package/app/api/assistant/route.ts +2 -2
  34. package/app/api/gateway/agent/route.ts +14 -0
  35. package/app/api/gateway/provider/balance/route.ts +5 -2
  36. package/app/api/gateway/sync.ts +97 -14
  37. package/app/api/setup/agent-image/route.ts +14 -42
  38. package/app/components/DashboardToolbar.tsx +1 -1
  39. package/app/gateway/PageClient.tsx +27 -32
  40. package/bin/rev4a.js +43 -41
  41. package/daemon.js +6 -6
  42. package/docs/ARCHITECTURE.md +95 -9
  43. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  44. package/docs/REV4A.md +54 -17
  45. package/docs/dev/API-REFERENCE.md +554 -100
  46. package/docs/dev/DATABASE.md +96 -0
  47. package/docs/dev/GATEWAY.md +21 -6
  48. package/docs/rag/DATA-FRESHNESS.md +6 -4
  49. package/docs/rag/GLOSSARY.md +12 -3
  50. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  51. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  52. package/instrumentation.ts +43 -0
  53. package/lib/agent-busy.ts +21 -0
  54. package/lib/agent-devices.ts +361 -0
  55. package/lib/agent-edit-state.ts +108 -0
  56. package/lib/agent-edit.ts +157 -0
  57. package/lib/agent-images.ts +375 -0
  58. package/lib/agent-ports-server.ts +27 -0
  59. package/lib/agent-ports.ts +68 -0
  60. package/lib/agent-recreate-state.ts +108 -0
  61. package/lib/agent-recreate.ts +305 -0
  62. package/lib/agent-restore-state.ts +107 -0
  63. package/lib/agent-restore.ts +135 -0
  64. package/lib/agent-setup.ts +66 -17
  65. package/lib/agent-update-state.ts +122 -0
  66. package/lib/agent-update.ts +448 -0
  67. package/lib/agent-versions.json +14 -0
  68. package/lib/agent-versions.ts +80 -0
  69. package/lib/buildAgentImage.ts +88 -290
  70. package/lib/channelManager.ts +149 -102
  71. package/lib/cold-backup.ts +354 -0
  72. package/lib/credentials/delivery.ts +3 -3
  73. package/lib/db-bootstrap.mjs +76 -0
  74. package/lib/docker-utils.ts +3 -3
  75. package/lib/provider-balance.ts +33 -12
  76. package/package.json +1 -1
@@ -1,135 +1,55 @@
1
- import { type NextRequest } from "next/server";
2
1
  /**
3
2
  * GET /api/agents/image-status
4
3
  *
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.
4
+ * Which OpenClaw versions of the agent base image are downloaded, whether the
5
+ * registry publishes a newer supported version, and whether a download is running.
7
6
  *
8
- * 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.
7
+ * ImageDownloadBanner polls this every 2 s. The local part is two Docker API calls;
8
+ * the registry lookup is cached for ten minutes and shared by concurrent requests
9
+ * (lib/agent-images.ts), so polling does not hit the network.
27
10
  */
28
- import { NextResponse } from 'next/server';
29
- import { execFile } from 'child_process';
30
- import { promisify } from 'util';
31
- import { getIsDownloading } from '@/lib/buildAgentImage';
11
+ import { NextResponse, type NextRequest } from 'next/server';
12
+ import { getDownloadingVersion, getIsDownloading } from '@/lib/buildAgentImage';
32
13
  import { requireAuthJWT } from '@/lib/rev4a-auth';
33
14
  import { dockerAvailable } from '@/lib/docker-socket';
34
-
35
- const execFileAsync = promisify(execFile);
15
+ import { listLocalAgentImages, localSupportedVersions, remoteSupportedVersions, type LocalAgentImage } from '@/lib/agent-images';
16
+ import { compareVersions } from '@/lib/agent-versions';
36
17
 
37
18
  export const dynamic = 'force-dynamic';
38
19
 
39
- const REGISTRY_IMAGE = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
40
- const LOCAL_IMAGE = 'openclaw-agent-base:latest';
41
- const REGISTRY_PREFIX = 'ghcr.io/flame0510/rev4a/openclaw-agent-base@';
42
-
43
- /**
44
- * How long a registry lookup is reused. Long enough that a 2s poll doesn't hit
45
- * the network, short enough that a freshly published base image surfaces on its
46
- * own. The cost of being stale here is a banner appearing a few minutes late.
47
- */
48
- const REMOTE_TTL_MS = 10 * 60 * 1000;
49
-
50
- let remoteCache: { digest: string | null; at: number } | null = null;
51
- /** Shared across concurrent requests so a burst of polls triggers one lookup. */
52
- let remoteInFlight: Promise<string | null> | null = null;
53
-
54
- /** The local image's ID and its registry digest, from a single inspect. */
55
- async function inspectLocalImage(): Promise<{ id: string; registryDigest: string | null } | null> {
56
- try {
57
- const { stdout } = await execFileAsync(
58
- 'docker',
59
- ['image', 'inspect', LOCAL_IMAGE, '--format', '{{json .}}'],
60
- { encoding: 'utf-8', timeout: 5000 },
61
- );
62
- const info = JSON.parse(stdout) as { Id?: string; RepoDigests?: string[] };
63
- const fromRegistry = (info.RepoDigests ?? []).find((d) => d.startsWith(REGISTRY_PREFIX));
64
- return {
65
- id: info.Id ?? '',
66
- registryDigest: fromRegistry ? fromRegistry.split('@')[1] ?? null : null,
67
- };
68
- } catch {
69
- return null;
70
- }
71
- }
72
-
73
- /** Manifest digest of the published image, cached for REMOTE_TTL_MS. */
74
- async function getRemoteDigest(): Promise<string | null> {
75
- if (remoteCache && Date.now() - remoteCache.at < REMOTE_TTL_MS) return remoteCache.digest;
76
- if (remoteInFlight) return remoteInFlight;
77
-
78
- remoteInFlight = (async () => {
79
- try {
80
- const { stdout } = await execFileAsync(
81
- 'docker',
82
- ['manifest', 'inspect', REGISTRY_IMAGE, '--verbose'],
83
- { encoding: 'utf-8', timeout: 15000 },
84
- );
85
- const manifest = JSON.parse(stdout) as { Descriptor?: { digest?: string } };
86
- // The descriptor digest is what RepoDigests can be compared against;
87
- // config.digest is a different hash entirely.
88
- return manifest?.Descriptor?.digest ?? null;
89
- } catch {
90
- return null;
91
- }
92
- })();
93
-
94
- try {
95
- const digest = await remoteInFlight;
96
- remoteCache = { digest, at: Date.now() };
97
- return digest;
98
- } finally {
99
- remoteInFlight = null;
100
- }
101
- }
102
-
103
20
  export async function GET(request: NextRequest): Promise<NextResponse> {
104
21
  const denied = await requireAuthJWT(request);
105
22
  if (denied) return denied;
106
23
 
107
- const downloading = getIsDownloading();
24
+ const base = { downloading: getIsDownloading(), downloadingVersion: getDownloadingVersion() };
108
25
 
109
26
  if (!dockerAvailable()) {
110
- return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
27
+ return NextResponse.json({
28
+ ...base, exists: false, needsUpdate: true, localVersions: [], newestLocal: null, available: null, registryReachable: false,
29
+ });
111
30
  }
112
31
 
113
- const local = await inspectLocalImage();
114
- if (!local) {
115
- return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
32
+ let images: LocalAgentImage[] = [];
33
+ try {
34
+ images = await listLocalAgentImages();
35
+ } catch {
36
+ // Docker answered nothing usable: report no local image.
116
37
  }
38
+ const localVersions = localSupportedVersions(images);
39
+ const newestLocal = localVersions[0] ?? null;
117
40
 
118
- // 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();
121
-
122
- let needsUpdate = false;
123
- if (remoteDigest) {
124
- // No registry digest locally means the image was built here and never
125
- // pulled, so it can't be compared — treat it as updatable.
126
- needsUpdate = local.registryDigest ? local.registryDigest !== remoteDigest : true;
127
- }
41
+ const remote = await remoteSupportedVersions();
42
+ const available = remote?.find(
43
+ (v) => !localVersions.includes(v) && (!newestLocal || compareVersions(v, newestLocal) > 0),
44
+ ) ?? null;
128
45
 
129
46
  return NextResponse.json({
130
- exists: true,
131
- needsUpdate,
132
- downloading,
133
- localId: local.id || null,
47
+ ...base,
48
+ exists: newestLocal !== null,
49
+ needsUpdate: newestLocal === null || available !== null,
50
+ localVersions,
51
+ newestLocal,
52
+ available,
53
+ registryReachable: remote !== null,
134
54
  });
135
55
  }
@@ -1,9 +1,8 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
5
2
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
3
  import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
4
+ import { containerOpenClawVersion, listLocalAgentImages, newestLocalSupportedVersion, type LocalAgentImage } from '@/lib/agent-images';
5
+ import { LOCAL_IMAGE_REPOSITORY, compareVersions } from '@/lib/agent-versions';
7
6
 
8
7
  export const dynamic = 'force-dynamic';
9
8
 
@@ -21,14 +20,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
21
20
  '/containers/json?all=true&filters={"label":["AGENT_ID"]}',
22
21
  );
23
22
 
24
- // Current image ID — used to detect outdated containers
25
- let currentImageId = '';
23
+ // Local agent images: each container's OpenClaw version, and the newest supported
24
+ // version downloaded, which decides `updateAvailable`.
25
+ let images: LocalAgentImage[] = [];
26
26
  try {
27
- const imageInspect = await dockerFetch('GET', '/images/openclaw-agent-base:latest/json');
28
- currentImageId = imageInspect.Id || '';
27
+ images = await listLocalAgentImages();
29
28
  } catch {
30
- // Image doesn't exist yet — all containers are fine
29
+ // No image list: versions come from inside running containers only.
31
30
  }
31
+ const newestLocal = newestLocalSupportedVersion(images);
32
32
 
33
33
  const agents = await Promise.all(
34
34
  containers.map(async (c: any) => {
@@ -36,7 +36,6 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
36
36
  let ip: string | null = null;
37
37
  let created: string | null = null;
38
38
  let env: string[] = [];
39
- let authToken: string | null = null;
40
39
  let controlPort: string | null = null;
41
40
  let portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> | null = null;
42
41
 
@@ -59,42 +58,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
59
58
  // Ports from inspect (HostConfig) — works even when container is stopped
60
59
  portBindings = inspect.HostConfig?.PortBindings || null;
61
60
 
62
- // Read auth token — always prefer the shared agents-token.json (user-managed)
63
- try {
64
- const tokenRaw = fs.readFileSync(
65
- AGENTS_TOKEN_FILE,
66
- 'utf-8'
67
- );
68
- const tokenData = JSON.parse(tokenRaw);
69
- if (tokenData.token) {
70
- authToken = tokenData.token;
71
- }
72
- } catch {
73
- // agents-token.json unavailable
74
- }
75
-
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.
88
- if (!authToken) {
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;
93
- }
94
-
95
61
  // 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.
62
+ // No link and no token are returned: the Open buttons go through
63
+ // /api/agents/[id]/open-control-ui, which builds the link server-side.
98
64
  // Read from portBindings (works on a stopped container) with a fallback
99
65
  // to c.Ports, which is only populated while running.
100
66
  const port3000Binding = portBindings?.['3000/tcp'];
@@ -109,7 +75,17 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
109
75
  // Falls back to 'custom' if not set (agent created before this feature).
110
76
  const image: string = c.Image || '';
111
77
  let template: string | null = null;
112
- if (image.startsWith('openclaw-agent-base')) {
78
+ // Every container here carries AGENT_ID, so its version is looked up even when
79
+ // its image is not tagged `openclaw-agent-base` (an image left untagged, or
80
+ // pinned by id). The image name alone would miss those.
81
+ const openclawVersion = await containerOpenClawVersion(
82
+ { name: (c.Names?.[0] || '').replace(/^\//, ''), imageId: c.ImageID || '', running: c.State === 'running' },
83
+ images,
84
+ );
85
+ const isAgentBase = image.startsWith(LOCAL_IMAGE_REPOSITORY)
86
+ || images.some((img) => img.id === c.ImageID)
87
+ || openclawVersion !== null;
88
+ if (isAgentBase) {
113
89
  const templateLine = env.find((e: string) => e.startsWith('AGENT_TEMPLATE='));
114
90
  template = templateLine ? templateLine.split('=').slice(1).join('=') : 'custom';
115
91
  }
@@ -147,10 +123,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
147
123
  created,
148
124
  env,
149
125
  controlPort,
150
- authToken,
151
- // Flag as outdated when the container's image differs from the current base image.
152
- // We compare raw ImageID digests — no fragile name-based filtering.
153
- imageOutdated: currentImageId ? c.ImageID !== currentImageId : false,
126
+ openclawVersion,
127
+ // A newer supported OpenClaw version is downloaded. Recreate keeps the agent's
128
+ // version; moving to the newer one is a separate, backed-up update.
129
+ updateAvailable: !!(openclawVersion && newestLocal && compareVersions(newestLocal, openclawVersion) > 0),
154
130
  };
155
131
  }),
156
132
  );
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import { NextResponse, type NextRequest } from 'next/server';
4
4
  import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
5
5
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+ import { agentBusyReason } from '@/lib/agent-busy';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
8
9
 
@@ -57,37 +58,59 @@ export async function PUT(request: NextRequest): Promise<NextResponse> {
57
58
  fs.writeFileSync(AGENTS_TOKEN_FILE, JSON.stringify(payload, null, 2));
58
59
 
59
60
  let containersUpdated = 0;
60
- let containerNames: string[] = [];
61
+ /** Containers left alone because an operation is running on them. */
62
+ const skipped: string[] = [];
63
+ /** Containers that could not be updated, with the reason. */
64
+ const failed: { container: string; error: string }[] = [];
65
+ let containers: { name: string; agentId: string }[] = [];
61
66
 
62
67
  try {
63
68
  const raw = execSync(
64
- `docker ps --filter "label=AGENT_ID" --format "{{.Names}}"`,
69
+ `docker ps --filter "label=AGENT_ID" --format '{{.Names}} {{.Label "AGENT_ID"}}'`,
65
70
  { encoding: 'utf-8', timeout: 5000 },
66
71
  ).trim();
67
- containerNames = raw ? raw.split('\n').map((name) => name.trim()).filter(Boolean) : [];
72
+ containers = raw
73
+ ? raw.split('\n').map((line) => {
74
+ const [name, agentId] = line.trim().split(/\s+/);
75
+ return { name: (name ?? '').trim(), agentId: (agentId ?? '').trim() };
76
+ }).filter((c) => c.name)
77
+ : [];
68
78
  } catch {
69
- containerNames = [];
79
+ containers = [];
70
80
  }
71
81
 
72
- for (const containerName of containerNames) {
82
+ for (const { name, agentId } of containers) {
73
83
  try {
84
+ // Restarting an agent mid-update, mid-recreate, mid-restore or mid-edit would cut
85
+ // the operation in half: leave it and say so.
86
+ if (agentId && (await agentBusyReason(agentId))) {
87
+ skipped.push(name);
88
+ continue;
89
+ }
74
90
  // Write token to /root/.agent-token (entrypoint reads this, overrides env var)
75
91
  execSync(
76
- `docker exec ${shellQuote(containerName)} sh -c 'echo ${shellQuote(token)} > /root/.agent-token'`,
92
+ `docker exec ${shellQuote(name)} sh -c 'echo ${shellQuote(token)} > /root/.agent-token'`,
77
93
  { encoding: 'utf-8', timeout: 10000 },
78
94
  );
79
95
  // Restart to pick up new token
80
- execSync(`docker restart ${shellQuote(containerName)}`, {
96
+ execSync(`docker restart ${shellQuote(name)}`, {
81
97
  encoding: 'utf-8',
82
98
  timeout: 20000,
83
99
  });
84
100
  containersUpdated += 1;
85
- } catch {
86
- // Skip failed containers and continue syncing the rest.
101
+ } catch (e) {
102
+ // Report the failure instead of swallowing it: a token that reached only some
103
+ // agents used to look like a full success.
104
+ failed.push({ container: name, error: (e as Error)?.message ?? String(e) });
87
105
  }
88
106
  }
89
107
 
90
- return NextResponse.json({ success: true, containersUpdated });
108
+ return NextResponse.json({
109
+ success: failed.length === 0,
110
+ containersUpdated,
111
+ ...(skipped.length ? { skipped } : {}),
112
+ ...(failed.length ? { failed } : {}),
113
+ });
91
114
  } catch (e: unknown) {
92
115
  return NextResponse.json(
93
116
  { success: false, error: (e as Error).message || 'Unknown error' },
@@ -25,8 +25,8 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
25
25
  '/agents': {
26
26
  title: 'Agents',
27
27
  description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when outdated or missing, animated progress bar when downloading from registry.',
28
- actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, auth token, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
29
- hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when an updated version is available on the registry — download to pick up CLI updates.',
28
+ actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
29
+ hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when a newer supported version is published on the registry — downloading makes it available to create and update agents on; it changes no agent by itself.',
30
30
  },
31
31
  '/agents/create': {
32
32
  title: 'Create Agent',
@@ -8,6 +8,7 @@
8
8
  * { "containerName": "openclaw-atlas", "model": "rev4a/deepseek-flash", "fallbacks": ["rev4a/deepseek-v4-pro"] }
9
9
  */
10
10
  import { NextRequest, NextResponse } from 'next/server';
11
+ import { agentBusyReason } from '@/lib/agent-busy';
11
12
  import { execSync } from 'child_process';
12
13
  import { requireAuthJWT } from '@/lib/rev4a-auth';
13
14
 
@@ -90,6 +91,19 @@ export async function PUT(request: NextRequest) {
90
91
  return NextResponse.json({ status: 'error', error: 'model is required' }, { status: 400 });
91
92
  }
92
93
 
94
+ // Refuse while a long operation is running on this agent: the write would race the
95
+ // recreate/migration that is replacing the container underneath it.
96
+ try {
97
+ const agentId = execSync(
98
+ `docker inspect --format '{{index .Config.Labels "AGENT_ID"}}' ${safeContainer}`,
99
+ { encoding: 'utf-8', timeout: 5000 },
100
+ ).trim();
101
+ if (agentId) {
102
+ const busy = await agentBusyReason(agentId);
103
+ if (busy) return NextResponse.json({ status: 'error', error: busy }, { status: 409 });
104
+ }
105
+ } catch { /* no container or no label: the rest of the route reports it */ }
106
+
93
107
  // Read current config
94
108
  const raw = readFileFromContainer(safeContainer, '/root/.openclaw/openclaw.json');
95
109
  if (!raw) {
@@ -7,7 +7,7 @@
7
7
  * Supported providers: deepseek, openrouter, glm
8
8
  */
9
9
  import { NextRequest, NextResponse } from 'next/server';
10
- import { fetchProviderBalance, type BalanceProvider } from '@/lib/provider-balance';
10
+ import { fetchProviderBalance, BalanceUnsupportedError, type BalanceProvider } from '@/lib/provider-balance';
11
11
  import { requireAuthJWT } from '@/lib/rev4a-auth';
12
12
 
13
13
  export const dynamic = 'force-dynamic';
@@ -33,6 +33,9 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
33
33
  return NextResponse.json({ provider, ...result });
34
34
  } catch (e: unknown) {
35
35
  const message = e instanceof Error ? e.message : String(e);
36
- return NextResponse.json({ provider, error: message }, { status: 502 });
36
+ // A permanent "no balance API for this account" is reported distinctly, so
37
+ // the UI can hide the badge instead of offering a retry that never works.
38
+ const reason = e instanceof BalanceUnsupportedError ? 'no-balance-api' : undefined;
39
+ return NextResponse.json({ provider, error: message, ...(reason && { reason }) }, { status: 502 });
37
40
  }
38
41
  }
@@ -19,6 +19,11 @@ import { readProviderKeys } from './provider/keys';
19
19
  import { bundledCatalogueStatus, loadOfferedModels, type ModelConfigEntry } from '@/lib/model-catalogue';
20
20
  import { singleFileFromTar } from '@/lib/container-file';
21
21
  import { detectProviderGatewayUrl } from '@/lib/docker-utils';
22
+ import { isSupportedVersion, modelInputFor, parseOpenClawVersion } from '@/lib/agent-versions';
23
+ import { activeUpdateAgentIds } from '@/lib/agent-update-state';
24
+ import { activeRecreateAgentIds } from '@/lib/agent-recreate-state';
25
+ import { activeRestoreAgentIds } from '@/lib/agent-restore-state';
26
+ import { activeEditAgentIds } from '@/lib/agent-edit-state';
22
27
 
23
28
  const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
24
29
 
@@ -31,19 +36,57 @@ const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
31
36
  * OpenClaw `input` array. Filters out values not in the allowed set
32
37
  * (text | image | video | audio).
33
38
  */
34
- const ALLOWED_INPUT = new Set(['text', 'image', 'video', 'audio']);
35
-
36
- function modalityToInput(modality: string): string[] {
39
+ /**
40
+ * Model `input` values an agent's OpenClaw version accepts. The list is per version
41
+ * (lib/agent-versions.json): 7.1-2 knows only `text | image`, and one value it does not
42
+ * know invalidates the whole generated catalogue ("model catalog load issue").
43
+ */
44
+ function modalityToInput(modality: string, version?: string | null): string[] {
45
+ const allowed = new Set(modelInputFor(version));
37
46
  const arrowIdx = modality.indexOf('->');
38
47
  const left = arrowIdx >= 0 ? modality.slice(0, arrowIdx) : modality;
39
- return left.split('+').map((s) => s.trim()).filter((s) => ALLOWED_INPUT.has(s));
48
+ return left.split('+').map((s) => s.trim()).filter((s) => allowed.has(s));
49
+ }
50
+
51
+ /** Where the image entrypoint records the version an agent last started on. */
52
+ const LAST_VERSION_PATH = '/root/.openclaw/.last-version';
53
+
54
+ /**
55
+ * The OpenClaw version a container runs, for its model `input` list. Best-effort and
56
+ * synchronous: the image tag when it names a version, else the `.last-version` file the
57
+ * entrypoint writes (through `docker exec` when running, `docker cp` when stopped).
58
+ * Null when nothing says — the caller then falls back to the newest version's list.
59
+ */
60
+ function containerVersionSync(name: string): string | null {
61
+ try {
62
+ const image = execFileSync('docker', ['inspect', '--format', '{{.Config.Image}}', name], {
63
+ timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
64
+ }).trim();
65
+ const fromTag = parseOpenClawVersion(image);
66
+ if (fromTag && isSupportedVersion(fromTag)) return fromTag;
67
+ } catch { /* no such container or no image: fall through */ }
68
+ try {
69
+ const raw = execFileSync('docker', ['exec', name, 'cat', LAST_VERSION_PATH], {
70
+ timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ const running = parseOpenClawVersion(raw);
73
+ if (running && isSupportedVersion(running)) return running;
74
+ } catch { /* not running or no file: try the stopped path */ }
75
+ try {
76
+ const file = singleFileFromTar(execFileSync('docker', ['cp', `${name}:${LAST_VERSION_PATH}`, '-'], {
77
+ timeout: 5000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
78
+ }));
79
+ const stopped = parseOpenClawVersion(file.body.toString('utf-8'));
80
+ if (stopped && isSupportedVersion(stopped)) return stopped;
81
+ } catch { /* nothing to read */ }
82
+ return null;
40
83
  }
41
84
 
42
- function getActiveRev4aModels(): { id: string; name: string; input?: string[] }[] {
85
+ function getActiveRev4aModels(version?: string | null): { id: string; name: string; input?: string[] }[] {
43
86
  return loadOfferedModels()
44
87
  .map((m) => {
45
88
  const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
46
- if (m.modality) entry.input = modalityToInput(m.modality);
89
+ if (m.modality) entry.input = modalityToInput(m.modality, version);
47
90
  return entry;
48
91
  });
49
92
  }
@@ -56,7 +99,23 @@ function firstLine(e: unknown): string {
56
99
  }
57
100
 
58
101
  /** A container carrying an AGENT_ID label, and whether it is running. */
59
- interface AgentContainer { name: string; running: boolean }
102
+ interface AgentContainer { name: string; running: boolean; agentId: string }
103
+
104
+ /**
105
+ * AGENT_IDs whose volume is being archived by a cold backup (lib/cold-backup.ts). Their
106
+ * config must not change while tar reads the volume; the next sync catches them up.
107
+ */
108
+ function agentsUnderBackup(): Set<string> {
109
+ try {
110
+ const raw = execSync(
111
+ `docker ps --filter "label=rev4a.backup.agent" --format '{{.Label "rev4a.backup.agent"}}'`,
112
+ { timeout: 5000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
113
+ ).trim();
114
+ return new Set(raw ? raw.split('\n').map((s) => s.trim()).filter(Boolean) : []);
115
+ } catch {
116
+ return new Set();
117
+ }
118
+ }
60
119
 
61
120
  /**
62
121
  * Every container carrying an AGENT_ID label, running or stopped.
@@ -74,7 +133,7 @@ function getAgentContainers(): AgentContainer[] {
74
133
  let raw: string;
75
134
  try {
76
135
  raw = execSync(
77
- `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}'`,
136
+ `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}\t{{.Label "AGENT_ID"}}'`,
78
137
  { timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
79
138
  ).trim();
80
139
  } catch (e) {
@@ -83,8 +142,8 @@ function getAgentContainers(): AgentContainer[] {
83
142
  if (!raw) return [];
84
143
  return raw.split('\n')
85
144
  .map((line) => {
86
- const [name, state] = line.split('\t');
87
- return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running' };
145
+ const [name, state, agentId] = line.split('\t');
146
+ return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running', agentId: (agentId ?? '').trim() };
88
147
  })
89
148
  .filter((c) => c.name);
90
149
  }
@@ -184,10 +243,10 @@ function writeContainerJson(container: AgentContainer, remotePath: string, data:
184
243
  * Shared between the create route and syncAllAgents — single source of truth
185
244
  * for the provider block injected into every agent container.
186
245
  */
187
- export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
246
+ export function buildRev4aProviderConfig(version?: string | null): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
188
247
  const providerKeys = readProviderKeys();
189
248
  const rev4aApiKey = providerKeys['rev4a'] || '';
190
- const activeModels = getActiveRev4aModels();
249
+ const activeModels = getActiveRev4aModels(version);
191
250
  return {
192
251
  baseUrl: PROVIDER_GATEWAY_BASE_URL,
193
252
  apiKey: rev4aApiKey,
@@ -211,7 +270,8 @@ export function patchRev4aProvider(container: string): void {
211
270
  if (bundledCatalogueStatus() === 'unavailable') {
212
271
  throw new Error('models.config.json could not be read; models.providers.rev4a left unchanged');
213
272
  }
214
- const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig() } } });
273
+ // The agent's own version: an older release rejects `input` values it does not know.
274
+ const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig(containerVersionSync(container)) } } });
215
275
  execFileSync('docker', ['exec', '-i', container, 'sh', '-c', 'openclaw config patch --stdin'], {
216
276
  timeout: 15000,
217
277
  stdio: 'pipe',
@@ -293,9 +353,32 @@ export function syncAllAgents(): SyncOutcome {
293
353
  }
294
354
 
295
355
  outcome.total = containers.length;
356
+ const underBackup = agentsUnderBackup();
357
+ // Any long operation replaces or rewrites the container/config: skip the agent.
358
+ let busyAgents = new Set<string>();
359
+ try {
360
+ busyAgents = new Set([
361
+ ...activeUpdateAgentIds(),
362
+ ...activeRecreateAgentIds(),
363
+ ...activeRestoreAgentIds(),
364
+ ...activeEditAgentIds(),
365
+ ]);
366
+ } catch {
367
+ // No operation state readable: nothing to skip on that account.
368
+ }
296
369
  for (const container of containers) {
370
+ if (busyAgents.has(container.agentId)) {
371
+ outcome.failed.push({ container: container.name, error: 'an operation of this agent is running; it applies Rev4a config itself' });
372
+ continue;
373
+ }
374
+ if (underBackup.has(container.agentId)) {
375
+ outcome.failed.push({ container: container.name, error: 'a cold backup of this agent is running; sync again once it finishes' });
376
+ continue;
377
+ }
297
378
  try {
298
- syncContainer(container, rev4aProviderConfig);
379
+ // Per agent: the `input` list follows the version that agent runs, so a 7.x
380
+ // release never receives a modality value it would reject.
381
+ syncContainer(container, buildRev4aProviderConfig(containerVersionSync(container.name)));
299
382
  outcome.synced.push(container.name);
300
383
  if (!container.running) outcome.stopped.push(container.name);
301
384
  } catch (e: unknown) {