@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,163 @@
1
+ /**
2
+ * GET /api/agents/models-summary
3
+ *
4
+ * One row per agent, running or not: which model it runs, and whether that model is in
5
+ * good standing. Feeds the model chip on the agent cards, so the list answers
6
+ * "what is this agent running, and is it fine?" without opening anything.
7
+ *
8
+ * That question had no cheap answer before: the model lived only inside each
9
+ * container's config, and the only route that read it walked the whole fleet.
10
+ * Three agents silently moved to DeepSeek V4.1, and one has been pointing at a
11
+ * model absent from its own catalogue, precisely because nothing surfaced it.
12
+ *
13
+ * Fan-out uses the NoFail variants: one unreachable container must degrade its
14
+ * own row, not blank the page. `mapWithConcurrency` is fail-fast, so a throwing
15
+ * mapper would discard every result already computed.
16
+ */
17
+ import { NextResponse } from 'next/server';
18
+ import { execFile } from 'child_process';
19
+ import { promisify } from 'util';
20
+ import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
21
+ import { loadModelsConfig, loadOfferedModels } from '@/lib/model-catalogue';
22
+ import { singleFileFromTar } from '@/lib/container-file';
23
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ export const dynamic = 'force-dynamic';
28
+
29
+ /** Matches the per-agent query cap used elsewhere for fleet walks. */
30
+ const CONCURRENCY = 6;
31
+
32
+ const CONFIG_PATH = '/root/.openclaw/openclaw.json';
33
+
34
+ /** A not-running agent's config, through `docker cp`. Empty string when unreadable. */
35
+ async function readNotRunningConfig(container: string): Promise<string> {
36
+ try {
37
+ const { stdout } = await execFileAsync('docker', ['cp', `${container}:${CONFIG_PATH}`, '-'], {
38
+ encoding: 'buffer', timeout: 8000, maxBuffer: 1024 * 1024,
39
+ });
40
+ return singleFileFromTar(stdout as unknown as Buffer).body.toString('utf-8');
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * - `missing` the primary is not in the catalogue at all
48
+ * - `disabled` in the catalogue but not offered: unchecked, or its provider has no key
49
+ * - `out-of-sync` offered by the gateway, absent from this container's synced copy
50
+ * - `deprecated` works, on a name upstream has retired
51
+ */
52
+ export type ModelHealth = 'ok' | 'deprecated' | 'missing' | 'disabled' | 'out-of-sync' | 'unset' | 'unknown';
53
+
54
+ export interface AgentModelRow {
55
+ agentId: string;
56
+ containerName: string;
57
+ primary: string | null;
58
+ fallbackCount: number;
59
+ /** First fallback, for the one-line hint on the card. The rest stay in the panel. */
60
+ firstFallback: string | null;
61
+ health: ModelHealth;
62
+ }
63
+
64
+ export async function GET(request: Request): Promise<NextResponse> {
65
+ const denied = await requireAuthJWT(request);
66
+ if (denied) return denied as any;
67
+
68
+ let agents: { agentId: string; containerName: string; running: boolean }[] = [];
69
+ try {
70
+ // `-a`: a stopped agent is the likeliest to be sitting on a model that has
71
+ // since been removed or disabled, and it needs a chip as much as any.
72
+ const { stdout } = await execFileAsync(
73
+ 'docker',
74
+ ['ps', '-a', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}\t{{.State}}'],
75
+ { encoding: 'utf8', timeout: 5000 },
76
+ );
77
+ agents = stdout.trim().split('\n')
78
+ .map((line) => {
79
+ const [agentId, containerName, state] = line.split('\t');
80
+ if (!containerName?.trim()) return null;
81
+ return {
82
+ agentId: agentId?.trim() || containerName.trim(),
83
+ containerName: containerName.trim(),
84
+ running: state?.trim() === 'running',
85
+ };
86
+ })
87
+ .filter(Boolean) as { agentId: string; containerName: string; running: boolean }[];
88
+ } catch (e) {
89
+ // Not an empty list: that reads as "every agent is fine".
90
+ return NextResponse.json(
91
+ { agents: [], error: `Docker is unreachable: ${(e as Error).message.split('\n')[0]}` },
92
+ { status: 503 },
93
+ );
94
+ }
95
+
96
+ if (agents.length === 0) return NextResponse.json({ agents: [] });
97
+
98
+ // Deprecation is a property of the catalogue, not of the container: read it once.
99
+ const catalogue = new Map(loadModelsConfig().map((m) => [m.id, m]));
100
+ // What the gateway will actually serve. The proxy refuses anything outside it,
101
+ // so this set, not the container's copy, decides whether an agent can work.
102
+ const offered = new Set(loadOfferedModels().map((m) => m.id));
103
+
104
+ const rows = await mapWithConcurrency(agents, CONCURRENCY, async (agent): Promise<AgentModelRow> => {
105
+ const base: AgentModelRow = {
106
+ agentId: agent.agentId,
107
+ containerName: agent.containerName,
108
+ primary: null,
109
+ fallbackCount: 0,
110
+ firstFallback: null,
111
+ health: 'unknown',
112
+ };
113
+
114
+ const raw = agent.running
115
+ ? await dockerExecNoFail(agent.containerName, ['cat', CONFIG_PATH], { timeoutMs: 8000 })
116
+ : await readNotRunningConfig(agent.containerName);
117
+ // An empty body means unreachable *or* a read killed by the deadline —
118
+ // `docker exec` exits 0 either way, so both stay 'unknown' rather than
119
+ // being reported as a healthy agent with no model.
120
+ if (!raw) return base;
121
+
122
+ let config: Record<string, unknown>;
123
+ try { config = JSON.parse(raw); } catch { return base; }
124
+
125
+ const defaults = ((config.agents ?? {}) as Record<string, unknown>).defaults ?? {};
126
+ const model = (defaults as Record<string, unknown>).model;
127
+ const primary = typeof model === 'string'
128
+ ? model
129
+ : (model && typeof model === 'object' ? (model as Record<string, unknown>).primary : null);
130
+ const fallbacks = model && typeof model === 'object' && Array.isArray((model as Record<string, unknown>).fallbacks)
131
+ ? ((model as Record<string, unknown>).fallbacks as unknown[])
132
+ : [];
133
+
134
+ if (typeof primary !== 'string' || !primary) return { ...base, health: 'unset' };
135
+
136
+ const bare = primary.replace(/^rev4a\//, '');
137
+ const models = ((config.models ?? {}) as Record<string, unknown>).providers ?? {};
138
+ const rev4a = ((models as Record<string, unknown>).rev4a ?? {}) as Record<string, unknown>;
139
+ const ids = (Array.isArray(rev4a.models) ? rev4a.models : [])
140
+ .map((m) => (m && typeof m === 'object' ? (m as Record<string, unknown>).id : null))
141
+ .filter((id): id is string => typeof id === 'string');
142
+
143
+ const entry = catalogue.get(bare);
144
+ let health: ModelHealth;
145
+ // Ordered by remedy. Each case asks something different of the operator, and
146
+ // the single 'missing' this replaced pointed at the wrong fix half the time.
147
+ if (!entry) health = 'missing'; // gone from the catalogue: pick another model
148
+ else if (!offered.has(bare)) health = 'disabled'; // exists, not offered: the proxy refuses it
149
+ else if (!ids.includes(bare)) health = 'out-of-sync'; // offered, never reached this container: Sync All
150
+ else if (entry.deprecated) health = 'deprecated'; // works, on a retired name
151
+ else health = 'ok';
152
+
153
+ return {
154
+ ...base,
155
+ primary,
156
+ fallbackCount: fallbacks.length,
157
+ firstFallback: typeof fallbacks[0] === 'string' ? (fallbacks[0] as string) : null,
158
+ health,
159
+ };
160
+ });
161
+
162
+ return NextResponse.json({ agents: rows });
163
+ }
@@ -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' },
@@ -2,8 +2,11 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { readProviderKeys } from '@/app/api/gateway/provider/keys';
3
3
  import { extractProviderAndModel, getApiKey, callUpstream } from '@/app/api/provider/upstream';
4
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+ import { isModelOffered, loadOfferedModels } from '@/lib/model-catalogue';
5
6
 
6
- const FALLBACK_MODEL = 'deepseek/deepseek-v4-flash';
7
+ // DeepSeek Flash (V4.1). Used only when the client names no model, and refused
8
+ // like any other if it has been unchecked on the Gateway page.
9
+ const FALLBACK_MODEL = 'deepseek/deepseek-flash';
7
10
 
8
11
  interface PageContext {
9
12
  title: string;
@@ -15,27 +18,27 @@ interface PageContext {
15
18
  const PAGE_CONTEXT: Record<string, PageContext> = {
16
19
  '/': {
17
20
  title: 'Dashboard',
18
- description: 'Main overview with live session list, cost summary (today, 7 days, 30 days by model), system health metrics (CPU, RAM, disk, load), and a real-time event feed.',
21
+ description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. It shows no CPU, RAM, disk or load average: those are collected by the daemon but no page displays them.',
19
22
  actions: ['Click any session row to open the Session Drawer for full details and tool call history', 'View cost breakdown by model and time period', 'See live events as they happen', 'Take a screenshot of the dashboard (mobile)'],
20
23
  hints: 'The Session Drawer shows you the complete tool call history for any session — just click a row.',
21
24
  },
22
25
  '/agents': {
23
26
  title: 'Agents',
24
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.',
25
- actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, auth token, Traefik URL)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
26
- 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.',
27
30
  },
28
31
  '/agents/create': {
29
32
  title: 'Create Agent',
30
- description: 'Wizard to create a new agent from a template. Choose a template, give it a name, select a model, set environment variables, and configure optional Traefik routing.',
31
- actions: ['Browse available agent templates', 'Name your agent and select its AI model', 'Add environment variables (KEY=*** pairs)', 'Optionally enable Traefik routing with a custom subdomain', 'Create the agent and start it immediately'],
32
- hints: 'Templates provide pre-built agent configurations. You can customize the image, model, and env vars after selecting a template.',
33
+ description: 'Two-step wizard to create a new agent from a template. Pick a template, then name it and optionally set a port range and a model.',
34
+ actions: ['Browse available agent templates', 'Name your agent and select its AI model', 'Set an optional port range (auto-assigned by default)', 'Create the agent and start it immediately'],
35
+ hints: 'Templates provide pre-built agent configurations. The model can be changed later from the agent\'s detail panel on the Agents page.',
33
36
  },
34
37
  '/gateway': {
35
38
  title: 'Gateway',
36
- description: 'Central gateway management page. Shows OpenClaw gateway status, all configured AI providers with their models, and each agent\'s model assignment with fallback models.',
37
- actions: ['Toggle models on/off per provider to control availability', 'View which model each agent uses as default', 'See fallback models configured for each agent', 'Check gateway health status and last sync time', 'Sync providers from the gateway configuration'],
38
- hints: 'The Gateway is the routing layer that connects agents to AI providers. Use this page to manage which models agents can access.',
39
+ description: 'Provider configuration: API keys, and which catalogue models this deployment offers. It does not assign models to agents.',
40
+ actions: ['Add, change or remove a provider API key', 'Toggle individual models on or off to control what the fleet is offered', 'Push the resulting catalogue to every agent container with Sync All Agents'],
41
+ hints: 'Unchecking a model here removes it everywhere, including for requests that name it directly. To change which model one agent runs, open that agent on the Agents page and use the Model section.',
39
42
  },
40
43
  '/crons': {
41
44
  title: 'Crons',
@@ -45,8 +48,8 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
45
48
  },
46
49
  '/containers': {
47
50
  title: 'Containers',
48
- description: 'List of all Docker containers running on the server (not just agents). Shows container ID, name, image, status, state, ports, and IP address.',
49
- actions: ['View all running containers with their details', 'Click "Terminal" on any container to open an interactive shell', 'Monitor container status and resource usage'],
51
+ description: 'List of all Docker containers on the server, running or stopped (not just agents). Shows container ID, name, image, status, state, ports, and IP address.',
52
+ actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports (no CPU or memory figures are shown)'],
50
53
  hints: 'Containers marked with an agentId belong to OpenClaw agents. Others are infrastructure containers like databases or reverse proxies.',
51
54
  },
52
55
  '/containers/terminal/[id]': {
@@ -64,7 +67,7 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
64
67
  '/lineage': {
65
68
  title: 'Lineage',
66
69
  description: 'Interactive graph showing parent-child relationships between agent sessions. Each node is a session, edges show who spawned whom.',
67
- actions: ['View the session hierarchy tree', 'Select a time period (1d/7d/30d) to filter the view', 'Click any session node to inspect its details', 'Zoom and pan through the graph'],
70
+ actions: ['View the session hierarchy tree', 'Select a time period (1d, 3d, 7d, 15d, 30d or all) to filter the view', 'Click any session node to inspect its details', 'Zoom and pan through the graph'],
68
71
  hints: 'When an agent spawns a child agent to do work, that relationship is shown here. Use the period selector to see recent activity.',
69
72
  },
70
73
  '/memory': {
@@ -81,9 +84,9 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
81
84
  },
82
85
  '/tools': {
83
86
  title: 'Tools',
84
- description: 'Catalog of all tools available to agents — built-in tools, MCP (Model Context Protocol) tools, plugin tools, and audio transcription tools.',
85
- actions: ['Browse the full tool catalog', 'See tool name, source package, and description', 'View audio/voice configuration settings', 'Check which MCP servers are connected'],
86
- hints: 'Each tool has a source (built-in, plugin, or MCP server). If a tool is missing, check the relevant plugin or MCP connection.',
87
+ description: 'Curated catalog of the OpenClaw built-in tools available to agents, grouped by area, plus audio and timezone settings. It does not list MCP servers or plugin tools.',
88
+ actions: ['Browse the full tool catalog', 'See each tool\'s name, description and status (Configured, Available, Coming soon)', 'View the audio transcription and time zone settings'],
89
+ hints: 'The catalog is a fixed list of OpenClaw built-in tools, not read from the agents. It shows no MCP servers or plugin tools, so a tool missing here says nothing about what an agent has.',
87
90
  },
88
91
  '/plugins': {
89
92
  title: 'Plugins',
@@ -177,6 +180,24 @@ export async function POST(request: NextRequest): Promise<Response> {
177
180
  ];
178
181
 
179
182
  const { provider, model: upstreamModel } = extractProviderAndModel({ model: effectiveModel });
183
+
184
+ // The chat panel loads its model list once, at mount, and keeps the choice in
185
+ // localStorage — so it can name a model that was unchecked on the Gateway page
186
+ // an hour ago, or one already gone when the preference was saved. Enforce the
187
+ // catalogue here rather than trusting the client to hold a current list.
188
+ if (!isModelOffered(provider, upstreamModel)) {
189
+ const alternative = loadOfferedModels()[0];
190
+ return NextResponse.json(
191
+ {
192
+ error: `Model '${provider}/${upstreamModel}' is not enabled for this deployment.`
193
+ + (alternative
194
+ ? ` Try ${alternative.id}, or enable it on the Gateway page.`
195
+ : ' No model is currently enabled — check the Gateway page.'),
196
+ },
197
+ { status: 400 },
198
+ );
199
+ }
200
+
180
201
  const providerKeys = readProviderKeys();
181
202
  const apiKey = getApiKey(provider, providerKeys);
182
203
 
@@ -5,9 +5,10 @@
5
5
  * Uses base64-safe write — no heredoc shell escaping issues.
6
6
  *
7
7
  * Body:
8
- * { "containerName": "openclaw-atlas", "model": "rev4a/deepseek-v4-flash", "fallbacks": ["rev4a/deepseek-v4-pro"] }
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
 
@@ -56,6 +57,21 @@ function readFileFromContainer(container: string, remotePath: string): string {
56
57
  }
57
58
  }
58
59
 
60
+ /**
61
+ * Fallbacks already on a model entry, for a request that did not send any.
62
+ *
63
+ * `fallbacks` omitted and `fallbacks: []` are different requests. The first means
64
+ * "leave them alone", the second "clear them". This route used to treat both as
65
+ * `[]`, so any caller that sent only `model` silently erased the list.
66
+ */
67
+ function existingFallbacks(model: unknown): string[] {
68
+ if (model && typeof model === 'object') {
69
+ const f = (model as { fallbacks?: unknown }).fallbacks;
70
+ if (Array.isArray(f)) return f.filter((x): x is string => typeof x === 'string');
71
+ }
72
+ return [];
73
+ }
74
+
59
75
  export async function PUT(request: NextRequest) {
60
76
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
61
77
  try {
@@ -75,6 +91,19 @@ export async function PUT(request: NextRequest) {
75
91
  return NextResponse.json({ status: 'error', error: 'model is required' }, { status: 400 });
76
92
  }
77
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
+
78
107
  // Read current config
79
108
  const raw = readFileFromContainer(safeContainer, '/root/.openclaw/openclaw.json');
80
109
  if (!raw) {
@@ -99,8 +128,8 @@ export async function PUT(request: NextRequest) {
99
128
  const mainAgent = list.find((a) => a.id === 'main');
100
129
  if (mainAgent) {
101
130
  const primary = ensureRev4aPrefix(body.model);
102
- let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks || []));
103
- // Remove the primary model dai fallback
131
+ let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks ?? existingFallbacks(mainAgent.model)));
132
+ // The primary never doubles as its own fallback.
104
133
  fallbacks = fallbacks.filter((fb) => fb !== primary);
105
134
  mainAgent.model = {
106
135
  primary,
@@ -111,7 +140,7 @@ export async function PUT(request: NextRequest) {
111
140
  const defaults = (agents.defaults || {}) as Record<string, unknown>;
112
141
  {
113
142
  const primary = ensureRev4aPrefix(body.model);
114
- let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks || []));
143
+ let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks ?? existingFallbacks(defaults.model)));
115
144
  fallbacks = fallbacks.filter((fb) => fb !== primary);
116
145
  defaults.model = {
117
146
  primary,
@@ -144,8 +173,10 @@ export async function PUT(request: NextRequest) {
144
173
  status: 'ok',
145
174
  agent: safeContainer,
146
175
  model: {
147
- primary: body.model,
148
- fallbacks: body.fallbacks || [],
176
+ // Report what was written, not what was asked: both prefixed, and the
177
+ // fallbacks as preserved or replaced.
178
+ primary: (defaults.model as { primary: string }).primary,
179
+ fallbacks: (defaults.model as { fallbacks: string[] }).fallbacks,
149
180
  },
150
181
  verify: { ok: verifyOk, bytes: verifyBytes },
151
182
  });
@@ -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
  }
@@ -15,8 +15,16 @@ function ensureDir(): void {
15
15
  }
16
16
  }
17
17
 
18
+ /** Best-effort: files written before 0600 was enforced are world-readable. */
19
+ function tightenMode(): void {
20
+ try {
21
+ if ((fs.statSync(KEYS_PATH).mode & 0o077) !== 0) fs.chmodSync(KEYS_PATH, 0o600);
22
+ } catch { /* absent, or not ours to change */ }
23
+ }
24
+
18
25
  export function readProviderKeys(): ProviderKeys {
19
26
  ensureDir();
27
+ tightenMode();
20
28
  try {
21
29
  const raw = fs.readFileSync(KEYS_PATH, 'utf-8');
22
30
  return JSON.parse(raw);
@@ -34,5 +42,9 @@ export function readProviderKeys(): ProviderKeys {
34
42
 
35
43
  export function writeProviderKeys(keys: ProviderKeys): void {
36
44
  ensureDir();
37
- fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2), 'utf-8');
45
+ // Every upstream credential and the gateway password live here: owner-only, like
46
+ // the .env file. `mode` applies only when the file is created, so an existing
47
+ // file is tightened explicitly.
48
+ fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2), { encoding: 'utf-8', mode: 0o600 });
49
+ fs.chmodSync(KEYS_PATH, 0o600);
38
50
  }