@flame0510/project-aether 1.2.0 → 1.3.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 (49) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/gateway/agent/route.ts +23 -6
  12. package/app/api/gateway/provider/keys.ts +13 -1
  13. package/app/api/gateway/provider/route.ts +43 -12
  14. package/app/api/gateway/sync.ts +248 -72
  15. package/app/api/models/route.ts +28 -34
  16. package/app/api/provider/auth.ts +65 -0
  17. package/app/api/provider/upstream.ts +9 -2
  18. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  19. package/app/api/provider/v1/models/route.ts +26 -133
  20. package/app/components/PulseChat.tsx +25 -39
  21. package/app/components/ui/RemoveButton.tsx +46 -0
  22. package/app/components/ui/Select.tsx +3 -2
  23. package/app/components/ui/index.ts +1 -0
  24. package/app/credentials/PageClient.tsx +2 -2
  25. package/app/gateway/PageClient.tsx +257 -673
  26. package/app/globals.css +8 -0
  27. package/app/lib/models-context.tsx +43 -7
  28. package/app/wizard/useWizard.ts +6 -1
  29. package/bin/rev4a.js +73 -9
  30. package/docs/ARCHITECTURE.md +16 -4
  31. package/docs/FRONTEND-ARCHITECTURE.md +24 -2
  32. package/docs/REV4A.md +40 -17
  33. package/docs/dev/API-REFERENCE.md +170 -79
  34. package/docs/dev/GATEWAY.md +231 -89
  35. package/docs/dev/PROVIDERS.md +26 -13
  36. package/docs/rag/DATA-FRESHNESS.md +57 -28
  37. package/docs/rag/GLOSSARY.md +16 -14
  38. package/docs/rag/REV4A-OVERVIEW.md +23 -24
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +5 -7
  40. package/instrumentation.ts +9 -1
  41. package/lib/agent-readiness.ts +110 -0
  42. package/lib/channelManager.ts +64 -22
  43. package/lib/container-file.ts +27 -0
  44. package/lib/model-catalogue.ts +140 -27
  45. package/lib/rev4a-paths.ts +0 -21
  46. package/model-pricing.json +118 -110
  47. package/models.config.json +27 -12
  48. package/package.json +1 -1
  49. package/app/api/gateway/route.ts +0 -191
@@ -5,7 +5,7 @@
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
11
  import { execSync } from 'child_process';
@@ -56,6 +56,21 @@ function readFileFromContainer(container: string, remotePath: string): string {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Fallbacks already on a model entry, for a request that did not send any.
61
+ *
62
+ * `fallbacks` omitted and `fallbacks: []` are different requests. The first means
63
+ * "leave them alone", the second "clear them". This route used to treat both as
64
+ * `[]`, so any caller that sent only `model` silently erased the list.
65
+ */
66
+ function existingFallbacks(model: unknown): string[] {
67
+ if (model && typeof model === 'object') {
68
+ const f = (model as { fallbacks?: unknown }).fallbacks;
69
+ if (Array.isArray(f)) return f.filter((x): x is string => typeof x === 'string');
70
+ }
71
+ return [];
72
+ }
73
+
59
74
  export async function PUT(request: NextRequest) {
60
75
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
61
76
  try {
@@ -99,8 +114,8 @@ export async function PUT(request: NextRequest) {
99
114
  const mainAgent = list.find((a) => a.id === 'main');
100
115
  if (mainAgent) {
101
116
  const primary = ensureRev4aPrefix(body.model);
102
- let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks || []));
103
- // Remove the primary model dai fallback
117
+ let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks ?? existingFallbacks(mainAgent.model)));
118
+ // The primary never doubles as its own fallback.
104
119
  fallbacks = fallbacks.filter((fb) => fb !== primary);
105
120
  mainAgent.model = {
106
121
  primary,
@@ -111,7 +126,7 @@ export async function PUT(request: NextRequest) {
111
126
  const defaults = (agents.defaults || {}) as Record<string, unknown>;
112
127
  {
113
128
  const primary = ensureRev4aPrefix(body.model);
114
- let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks || []));
129
+ let fallbacks = dedupe(ensureRev4aPrefixes(body.fallbacks ?? existingFallbacks(defaults.model)));
115
130
  fallbacks = fallbacks.filter((fb) => fb !== primary);
116
131
  defaults.model = {
117
132
  primary,
@@ -144,8 +159,10 @@ export async function PUT(request: NextRequest) {
144
159
  status: 'ok',
145
160
  agent: safeContainer,
146
161
  model: {
147
- primary: body.model,
148
- fallbacks: body.fallbacks || [],
162
+ // Report what was written, not what was asked: both prefixed, and the
163
+ // fallbacks as preserved or replaced.
164
+ primary: (defaults.model as { primary: string }).primary,
165
+ fallbacks: (defaults.model as { fallbacks: string[] }).fallbacks,
149
166
  },
150
167
  verify: { ok: verifyOk, bytes: verifyBytes },
151
168
  });
@@ -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
  }
@@ -8,7 +8,7 @@
8
8
  import { NextRequest, NextResponse } from 'next/server';
9
9
  import { execSync } from 'child_process';
10
10
  import { readProviderKeys, writeProviderKeys } from './keys';
11
- import { syncAllAgents } from '../sync';
11
+ import { dockerUnreachableReason, syncAllAgents, type SyncOutcome } from '../sync';
12
12
  import { detectProviderGatewayUrl } from '@/lib/docker-utils';
13
13
  import { getPricing } from '@/lib/model-pricing';
14
14
  import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
@@ -192,6 +192,20 @@ export async function GET(request: NextRequest) {
192
192
  return NextResponse.json({ providers });
193
193
  }
194
194
 
195
+ /**
196
+ * Run the sync without letting an unexpected throw turn a committed change into a
197
+ * 500. `syncAllAgents` already reports Docker and per-container failures as data;
198
+ * this only guards the rest.
199
+ */
200
+ function runSync(): SyncOutcome {
201
+ try {
202
+ return syncAllAgents();
203
+ } catch (e) {
204
+ const msg = e instanceof Error ? e.message : String(e);
205
+ return { ok: false, activeModels: 0, total: 0, synced: [], stopped: [], failed: [], configError: msg, summary: `Not applied to any agent: ${msg}.` };
206
+ }
207
+ }
208
+
195
209
  export async function POST(request: NextRequest) {
196
210
  const denied = await requireAuthJWT(request);
197
211
  if (denied) return denied;
@@ -199,8 +213,12 @@ export async function POST(request: NextRequest) {
199
213
  const body: { provider?: string; apiKey?: string; _syncOnly?: boolean } = await request.json();
200
214
 
201
215
  if (body._syncOnly) {
202
- const syncResults = syncAllAgents();
203
- return NextResponse.json({ status: 'ok', sync: syncResults });
216
+ // Here the sync *is* the operation, so its outcome is the status.
217
+ const sync = runSync();
218
+ return NextResponse.json(
219
+ sync.ok ? { status: 'ok', sync } : { status: 'error', error: sync.summary, sync },
220
+ { status: sync.ok ? 200 : 502 },
221
+ );
204
222
  }
205
223
 
206
224
  const providerName = body.provider;
@@ -210,15 +228,11 @@ export async function POST(request: NextRequest) {
210
228
  const apiKeyValue = body.apiKey;
211
229
  const isRemove = !apiKeyValue || apiKeyValue.trim() === '';
212
230
  setProviderApiKey(providerName, isRemove ? null : apiKeyValue!.trim());
213
- let syncResults: string[] = [];
214
- try {
215
- syncResults = syncAllAgents();
216
- } catch (se) {
217
- syncResults = [`Sync error: ${se instanceof Error ? se.message : String(se)}`];
218
- }
231
+ // The key is saved whatever the sync does next; report the two separately.
232
+ const sync = runSync();
219
233
  return NextResponse.json({
220
234
  status: 'ok', provider: providerName, configured: !isRemove,
221
- sync: syncResults,
235
+ sync,
222
236
  });
223
237
  } catch (e: unknown) {
224
238
  return NextResponse.json({ status: 'error', error: e instanceof Error ? e.message : String(e) }, { status: 500 });
@@ -238,9 +252,26 @@ export async function PUT(request: NextRequest) {
238
252
  const idx = models.findIndex((m) => m.id === modelId);
239
253
  if (idx === -1) return NextResponse.json({ status: 'error', error: `Model not found: ${modelId}` }, { status: 404 });
240
254
 
255
+ // Refuse up front when the sync cannot happen at all. Saving anyway would leave
256
+ // the checkbox on /gateway saying one thing and every agent holding another.
257
+ // Only a whole-fleet outage is refused this way: once Docker answers, a container
258
+ // that fails individually is reported in `sync`, because undoing the override
259
+ // would not undo the containers that did receive it. Provider key saves are not
260
+ // gated like this — the first-run wizard saves keys without reading the response
261
+ // and may run before Docker is up, so a refusal there would be silent.
262
+ const unreachable = dockerUnreachableReason();
263
+ if (unreachable) {
264
+ return NextResponse.json({
265
+ status: 'error',
266
+ error: `Not changed: Docker is unreachable (${unreachable}). Nothing was saved, so the Gateway still matches the agents.`,
267
+ }, { status: 503 });
268
+ }
269
+
241
270
  toggleModelOverride(modelId, enabled);
242
- const syncResults = syncAllAgents();
243
- return NextResponse.json({ status: 'ok', modelId, enabled, provider: models[idx].provider, sync: syncResults });
271
+ // `status` is the toggle, committed at this point. `sync` is whether the fleet
272
+ // got it — reported alongside, not folded into `status`.
273
+ const sync = runSync();
274
+ return NextResponse.json({ status: 'ok', modelId, enabled, provider: models[idx].provider, sync });
244
275
  } catch (e: unknown) {
245
276
  return NextResponse.json({ status: 'error', error: e instanceof Error ? e.message : String(e) }, { status: 500 });
246
277
  }
@@ -2,16 +2,22 @@
2
2
  * Agent Gateway Sync
3
3
  *
4
4
  * Syncs the models.providers.rev4a config block to all agent containers
5
- * (or a single container) without touching model references (primary/fallback).
5
+ * without touching model references (primary/fallback).
6
6
  *
7
7
  * Import and call:
8
8
  * syncAllAgents() — sync all containers with AGENT_ID label
9
- * syncAgent('container') — sync a single container
9
+ *
10
+ * A single-container `syncAgent()` used to be exported too. Nothing called it —
11
+ * the create route builds the block with `buildRev4aProviderConfig()` and writes
12
+ * it with `openclaw config patch` itself — so it was removed.
10
13
  */
11
- import { execSync } from 'child_process';
14
+ import { execSync, execFileSync } from 'child_process';
12
15
  import * as fs from 'fs';
16
+ import * as os from 'os';
17
+ import * as path from 'path';
13
18
  import { readProviderKeys } from './provider/keys';
14
- import { loadModelsConfig, type ModelConfigEntry } from '@/lib/model-catalogue';
19
+ import { bundledCatalogueStatus, loadOfferedModels, type ModelConfigEntry } from '@/lib/model-catalogue';
20
+ import { singleFileFromTar } from '@/lib/container-file';
15
21
  import { detectProviderGatewayUrl } from '@/lib/docker-utils';
16
22
 
17
23
  const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
@@ -33,11 +39,8 @@ function modalityToInput(modality: string): string[] {
33
39
  return left.split('+').map((s) => s.trim()).filter((s) => ALLOWED_INPUT.has(s));
34
40
  }
35
41
 
36
- function getActiveRev4aModels(models: ModelConfigEntry[]): { id: string; name: string; input?: string[] }[] {
37
- const providerKeys = readProviderKeys();
38
- const configuredProviders = Object.keys(providerKeys);
39
- return models
40
- .filter((m) => m.enabled && configuredProviders.includes(m.provider))
42
+ function getActiveRev4aModels(): { id: string; name: string; input?: string[] }[] {
43
+ return loadOfferedModels()
41
44
  .map((m) => {
42
45
  const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
43
46
  if (m.modality) entry.input = modalityToInput(m.modality);
@@ -45,54 +48,146 @@ function getActiveRev4aModels(models: ModelConfigEntry[]): { id: string; name: s
45
48
  });
46
49
  }
47
50
 
48
- function getAgentContainers(): string[] {
51
+ /** A readable cause from a failed child process: its stderr if any, else the message. */
52
+ function firstLine(e: unknown): string {
53
+ const err = e as { stderr?: string | Buffer; message?: string };
54
+ const text = (err.stderr ? err.stderr.toString() : '') || err.message || 'unknown error';
55
+ return text.trim().split('\n')[0];
56
+ }
57
+
58
+ /** A container carrying an AGENT_ID label, and whether it is running. */
59
+ interface AgentContainer { name: string; running: boolean }
60
+
61
+ /**
62
+ * Every container carrying an AGENT_ID label, running or stopped.
63
+ *
64
+ * Throws when Docker cannot be asked. It used to catch that and return `[]`,
65
+ * which made "the Docker daemon is not running" indistinguishable from "there are
66
+ * no agents": a model toggle made while Docker was stopped synced to nobody and
67
+ * still reported success. An empty list now means only the second.
68
+ *
69
+ * Stopped agents are included on purpose. This used to run `docker ps` without
70
+ * `-a`, so a catalogue change made while an agent was stopped skipped it, and
71
+ * starting it later brought back the old catalogue until someone ran Sync All.
72
+ */
73
+ function getAgentContainers(): AgentContainer[] {
74
+ let raw: string;
49
75
  try {
50
- const raw = execSync(
51
- `docker ps --filter "label=AGENT_ID" --format '{{.Names}}'`,
52
- { timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8' },
76
+ raw = execSync(
77
+ `docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}\t{{.State}}'`,
78
+ { timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
53
79
  ).trim();
54
- if (!raw) return [];
55
- return raw.split('\n');
56
- } catch {
57
- return [];
80
+ } catch (e) {
81
+ throw new Error(firstLine(e));
82
+ }
83
+ if (!raw) return [];
84
+ return raw.split('\n')
85
+ .map((line) => {
86
+ const [name, state] = line.split('\t');
87
+ return { name: (name ?? '').trim(), running: (state ?? '').trim() === 'running' };
88
+ })
89
+ .filter((c) => c.name);
90
+ }
91
+
92
+ /**
93
+ * Why Docker cannot be asked right now, or undefined when it can.
94
+ *
95
+ * For callers that must decide *before* committing a change whether its sync can
96
+ * happen at all. A model toggle made while Docker was unreachable used to be saved
97
+ * and then reach no container, leaving the Gateway's checkboxes out of step with
98
+ * what the agents hold; checking first lets it be refused cleanly instead.
99
+ */
100
+ export function dockerUnreachableReason(): string | undefined {
101
+ try {
102
+ getAgentContainers();
103
+ return undefined;
104
+ } catch (e) {
105
+ return (e as Error).message;
58
106
  }
59
107
  }
60
108
 
61
- function readContainerJson(container: string, remotePath: string): Record<string, unknown> {
109
+
110
+ /**
111
+ * Read and parse a container's JSON config, running or stopped.
112
+ *
113
+ * Throws on any failure. It used to return `{}`, and the caller wrote that back
114
+ * with only the provider block added — so a single timed-out read replaced an
115
+ * agent's whole config (agents, gateway, channels) with `{ models: { providers:
116
+ * { rev4a } } }`. An empty object is refused for the same reason. Also returns
117
+ * the file mode, so a stopped container's file is written back unchanged in that
118
+ * respect.
119
+ */
120
+ function readContainerJson(container: AgentContainer, remotePath: string): { config: Record<string, unknown>; mode: number } {
121
+ let raw: string;
122
+ let mode = 0o600;
123
+ if (container.running) {
124
+ raw = execFileSync('docker', ['exec', container.name, 'cat', remotePath], {
125
+ timeout: 8000, maxBuffer: 512 * 1024, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
126
+ });
127
+ } else {
128
+ const file = singleFileFromTar(execFileSync('docker', ['cp', `${container.name}:${remotePath}`, '-'], {
129
+ timeout: 8000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
130
+ }));
131
+ raw = file.body.toString('utf-8');
132
+ mode = file.mode;
133
+ }
134
+ let parsed: unknown;
62
135
  try {
63
- const raw = execSync(
64
- `docker exec ${container} cat ${remotePath}`,
65
- { timeout: 8000, maxBuffer: 512 * 1024, encoding: 'utf-8' },
66
- );
67
- return JSON.parse(raw);
136
+ parsed = JSON.parse(raw);
68
137
  } catch {
69
- return {};
138
+ // Not the parser's message: V8 quotes a slice of the input, this file holds
139
+ // tokens, and the error ends up in the sync summary shown in the browser.
140
+ throw new Error(`${remotePath} is not valid JSON; refusing to overwrite it`);
141
+ }
142
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
143
+ throw new Error(`${remotePath} is empty or not a JSON object; refusing to overwrite it`);
70
144
  }
145
+ return { config: parsed as Record<string, unknown>, mode };
71
146
  }
72
147
 
73
- function writeContainerJson(container: string, remotePath: string, data: Record<string, unknown>): void {
148
+ /**
149
+ * Write a container's JSON config, running or stopped.
150
+ *
151
+ * Running: through `docker exec`, truncating the file in place so its mode and
152
+ * owner stay as they are, and OpenClaw hot-applies the change. Stopped: through
153
+ * `docker cp`, the only way to reach it; the file is staged on the host with the
154
+ * original mode, lands on the agent's volume, and is read at next start.
155
+ */
156
+ function writeContainerJson(container: AgentContainer, remotePath: string, data: Record<string, unknown>, mode: number): void {
74
157
  const jsonStr = JSON.stringify(data, null, 2);
75
- // Use shell-quoted heredoc to avoid word-splitting on paths with spaces
76
- execSync(
77
- `docker exec -i ${container} sh -c "cat > '${remotePath}'"`,
78
- { timeout: 10000, maxBuffer: 1024 * 1024, input: jsonStr },
79
- );
158
+ if (container.running) {
159
+ execFileSync('docker', ['exec', '-i', container.name, 'sh', '-c', 'cat > "$1"', 'sh', remotePath], {
160
+ timeout: 10000, maxBuffer: 1024 * 1024, input: jsonStr, stdio: ['pipe', 'pipe', 'pipe'],
161
+ });
162
+ return;
163
+ }
164
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rev4a-sync-'));
165
+ const staged = path.join(dir, path.basename(remotePath));
166
+ try {
167
+ fs.writeFileSync(staged, jsonStr);
168
+ fs.chmodSync(staged, mode);
169
+ execFileSync('docker', ['cp', staged, `${container.name}:${remotePath}`], {
170
+ timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
171
+ });
172
+ } finally {
173
+ fs.rmSync(dir, { recursive: true, force: true });
174
+ }
80
175
  }
81
176
 
177
+
82
178
  /* ------------------------------------------------------------------ */
83
179
  /* Sync functions */
84
180
  /* ------------------------------------------------------------------ */
85
181
 
86
182
  /**
87
183
  * Build the models.providers.rev4a config object from local state.
88
- * Shared between the create wizard and syncAgent — single source of truth
184
+ * Shared between the create route and syncAllAgents — single source of truth
89
185
  * for the provider block injected into every agent container.
90
186
  */
91
187
  export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
92
188
  const providerKeys = readProviderKeys();
93
189
  const rev4aApiKey = providerKeys['rev4a'] || '';
94
- const allModels = loadModelsConfig();
95
- const activeModels = getActiveRev4aModels(allModels);
190
+ const activeModels = getActiveRev4aModels();
96
191
  return {
97
192
  baseUrl: PROVIDER_GATEWAY_BASE_URL,
98
193
  apiKey: rev4aApiKey,
@@ -102,65 +197,146 @@ export function buildRev4aProviderConfig(): { baseUrl: string; apiKey: string; a
102
197
  }
103
198
 
104
199
  /**
105
- * Sync models.providers.rev4a to all agent containers.
106
- * Does NOT touch agents.defaults.model or agents.list[].model.
200
+ * Write `models.providers.rev4a` into one running container.
201
+ *
202
+ * Throws instead of writing when the catalogue could not be read: an unreadable
203
+ * catalogue is not an empty one, and patching it in would strip every model from
204
+ * the agent. `syncAllAgents()` refuses for the same reason.
205
+ *
206
+ * No gateway restart: `config patch` reports "Change will apply without restarting
207
+ * the gateway" for a models-only patch, and the Gateway watches openclaw.json with
208
+ * gateway.reload defaulting to hybrid.
209
+ */
210
+ export function patchRev4aProvider(container: string): void {
211
+ if (bundledCatalogueStatus() === 'unavailable') {
212
+ throw new Error('models.config.json could not be read; models.providers.rev4a left unchanged');
213
+ }
214
+ const input = JSON.stringify({ models: { providers: { rev4a: buildRev4aProviderConfig() } } });
215
+ execFileSync('docker', ['exec', '-i', container, 'sh', '-c', 'openclaw config patch --stdin'], {
216
+ timeout: 15000,
217
+ stdio: 'pipe',
218
+ input,
219
+ });
220
+ }
221
+
222
+ /** What a sync did, container by container. */
223
+ export interface SyncOutcome {
224
+ /**
225
+ * True only when every agent container received the catalogue. Zero agents is
226
+ * ok — there is nothing to sync, which is the normal state on first run.
227
+ */
228
+ ok: boolean;
229
+ /** Models written into each container. */
230
+ activeModels: number;
231
+ /** Agent containers found. */
232
+ total: number;
233
+ synced: string[];
234
+ /** Of `synced`, the ones not running (stopped, paused, created): written with `docker cp`. Applied when started, or resumed. */
235
+ stopped: string[];
236
+ failed: { container: string; error: string }[];
237
+ /** Docker could not list containers at all, so nothing was attempted. */
238
+ dockerError?: string;
239
+ /** The catalogue block could not be built, so nothing was attempted. */
240
+ configError?: string;
241
+ /** One line for a person. Built here so every caller says the same thing. */
242
+ summary: string;
243
+ }
244
+
245
+ /** ", 2 of them not running" — those apply the change later, which is worth saying. */
246
+ function stoppedNote(outcome: SyncOutcome): string {
247
+ return outcome.stopped.length ? `, ${outcome.stopped.length} of them not running` : '';
248
+ }
249
+
250
+ /**
251
+ * Push the offered catalogue into every agent container, and say exactly what
252
+ * happened.
253
+ *
254
+ * Returns an outcome instead of throwing: callers run this *after* committing a
255
+ * change — a key saved, a model toggled — and a failed propagation must not be
256
+ * reported as a failed save, because the save stands. The old signature returned
257
+ * loose strings that no caller inspected, so a sync that reached no container
258
+ * surfaced as success.
107
259
  */
108
- export function syncAllAgents(): string[] {
260
+ export function syncAllAgents(): SyncOutcome {
109
261
  const rev4aProviderConfig = buildRev4aProviderConfig();
262
+ const outcome: SyncOutcome = {
263
+ ok: false,
264
+ activeModels: rev4aProviderConfig.models.length,
265
+ total: 0,
266
+ synced: [],
267
+ stopped: [],
268
+ failed: [],
269
+ summary: '',
270
+ };
271
+
110
272
  if (!rev4aProviderConfig.apiKey) {
111
- return ['ERROR: REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)'];
273
+ outcome.configError = 'REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)';
274
+ outcome.summary = `Not applied to any agent: ${outcome.configError}.`;
275
+ return outcome;
112
276
  }
113
277
 
114
- const containers = getAgentContainers();
115
- const results: string[] = [`Active models: ${rev4aProviderConfig.models.length} across ${containers.length} agent(s)`];
278
+ // A catalogue that could not be read is not an empty catalogue: pushing it would
279
+ // strip every model from every agent.
280
+ if (bundledCatalogueStatus() === 'unavailable') {
281
+ outcome.configError = 'models.config.json could not be read, so there is no catalogue to push';
282
+ outcome.summary = `Not applied to any agent: ${outcome.configError}.`;
283
+ return outcome;
284
+ }
116
285
 
286
+ let containers: AgentContainer[];
287
+ try {
288
+ containers = getAgentContainers();
289
+ } catch (e) {
290
+ outcome.dockerError = (e as Error).message;
291
+ outcome.summary = `Not applied to any agent: Docker is unreachable (${outcome.dockerError}).`;
292
+ return outcome;
293
+ }
294
+
295
+ outcome.total = containers.length;
117
296
  for (const container of containers) {
118
297
  try {
119
- const result = syncContainer(container, rev4aProviderConfig);
120
- results.push(result);
298
+ syncContainer(container, rev4aProviderConfig);
299
+ outcome.synced.push(container.name);
300
+ if (!container.running) outcome.stopped.push(container.name);
121
301
  } catch (e: unknown) {
122
- const err = e as { stderr?: string; message?: string };
123
- results.push(`${container}: ERROR ${err.stderr || err.message || 'unknown'}`);
302
+ outcome.failed.push({ container: container.name, error: firstLine(e) });
124
303
  }
125
304
  }
126
305
 
127
- if (containers.length === 0) {
128
- results.push('No agent containers found');
306
+ outcome.ok = outcome.failed.length === 0;
307
+ if (outcome.total === 0) {
308
+ outcome.summary = 'No agent containers to sync.';
309
+ } else if (outcome.ok) {
310
+ outcome.summary = `Synced ${outcome.synced.length} of ${outcome.total} agent(s)${stoppedNote(outcome)}.`;
311
+ } else {
312
+ const names = outcome.failed.map((f) => `${f.container} (${f.error})`).join(', ');
313
+ outcome.summary = `Synced ${outcome.synced.length} of ${outcome.total} agent(s)${stoppedNote(outcome)}. Not applied to: ${names}.`;
129
314
  }
130
-
131
- return results;
315
+ return outcome;
132
316
  }
133
317
 
134
318
  /**
135
- * Sync models.providers.rev4a to a single agent container.
136
- * Does NOT touch agents.defaults.model or agents.list[].model.
137
- */
138
- export function syncAgent(containerName: string): string {
139
- const rev4aProviderConfig = buildRev4aProviderConfig();
140
- if (!rev4aProviderConfig.apiKey) {
141
- throw new Error('REV4A_API_KEY not found in provider-keys.json (add "rev4a" key)');
142
- }
143
- return syncContainer(containerName, rev4aProviderConfig);
144
- }
145
-
146
- /**
147
- * Internal: write models.providers.rev4a to one container and restart its gateway.
319
+ * Internal: write models.providers.rev4a into one container's openclaw.json.
148
320
  */
149
321
  function syncContainer(
150
- container: string,
322
+ container: AgentContainer,
151
323
  rev4aProviderConfig: { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string }[] },
152
324
  ): string {
153
325
  const remotePath = '/root/.openclaw/openclaw.json';
154
- const config = readContainerJson(container, remotePath) as Record<string, unknown>;
326
+ // Read first. If this throws, the container is reported as failed and nothing
327
+ // below runs — in particular nothing is written.
328
+ const { config, mode } = readContainerJson(container, remotePath);
155
329
 
156
- // Clean up stale files from old versions
157
- try {
158
- execSync(
159
- `docker exec ${container} sh -c 'rm -f /root/.openclaw/agents/main/agent/models.json /root/.openclaw/agents/main/agent/auth-profiles.json'`,
160
- { timeout: 5000 },
161
- );
162
- } catch {
163
- // non-fatal
330
+ // Clean up stale files from old versions. Needs exec, so running containers
331
+ // only; a stopped one is cleaned on the first sync after it starts.
332
+ if (container.running) {
333
+ try {
334
+ execFileSync('docker', ['exec', container.name, 'rm', '-f',
335
+ '/root/.openclaw/agents/main/agent/models.json',
336
+ '/root/.openclaw/agents/main/agent/auth-profiles.json'], { timeout: 5000, stdio: 'ignore' });
337
+ } catch {
338
+ // non-fatal
339
+ }
164
340
  }
165
341
 
166
342
  // Write/overwrite models.providers.rev4a — do NOT touch agents.*
@@ -169,7 +345,7 @@ function syncContainer(
169
345
  (models.providers as Record<string, unknown>)['rev4a'] = rev4aProviderConfig;
170
346
  config.models = models;
171
347
 
172
- writeContainerJson(container, remotePath, config);
348
+ writeContainerJson(container, remotePath, config, mode);
173
349
 
174
350
  // No gateway restart: this only writes models.providers.rev4a, and the Gateway
175
351
  // watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
@@ -182,5 +358,5 @@ function syncContainer(
182
358
  // The restart used to cost ~7.5s per container, serially — roughly 52s of
183
359
  // fully blocked event loop on a seven-agent host, every time a provider key or
184
360
  // model toggle was saved, to do something the Gateway had already done.
185
- return `${container}: synced`;
361
+ return `${container.name}: synced`;
186
362
  }