@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.
- package/README.md +3 -1
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -169
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +91 -145
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- 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
|
+
}
|
package/app/api/agents/route.ts
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
currentImageId = imageInspect.Id || '';
|
|
27
|
+
images = await listLocalAgentImages();
|
|
29
28
|
} catch {
|
|
30
|
-
//
|
|
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
|
-
//
|
|
97
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
69
|
+
`docker ps --filter "label=AGENT_ID" --format '{{.Names}} {{.Label "AGENT_ID"}}'`,
|
|
65
70
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
66
71
|
).trim();
|
|
67
|
-
|
|
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
|
-
|
|
79
|
+
containers = [];
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
for (const
|
|
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(
|
|
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(
|
|
96
|
+
execSync(`docker restart ${shellQuote(name)}`, {
|
|
81
97
|
encoding: 'utf-8',
|
|
82
98
|
timeout: 20000,
|
|
83
99
|
});
|
|
84
100
|
containersUpdated += 1;
|
|
85
|
-
} catch {
|
|
86
|
-
//
|
|
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({
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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: '
|
|
31
|
-
actions: ['Browse available agent templates', 'Name your agent and select its AI model', '
|
|
32
|
-
hints: 'Templates provide pre-built agent configurations.
|
|
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: '
|
|
37
|
-
actions: ['
|
|
38
|
-
hints: '
|
|
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
|
|
49
|
-
actions: ['View
|
|
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
|
|
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: '
|
|
85
|
-
actions: ['Browse the full tool catalog', 'See tool name,
|
|
86
|
-
hints: '
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|