@flame0510/project-aether 1.1.12 → 1.1.14

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 (72) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +140 -34
  3. package/app/agents/create/PageClient.tsx +11 -4
  4. package/app/api/agents/[id]/recreate/route.ts +3 -3
  5. package/app/api/agents/[id]/route.ts +3 -1
  6. package/app/api/agents/channels-summary/route.ts +48 -17
  7. package/app/api/agents/create/route.ts +32 -13
  8. package/app/api/agents/download-image/route.ts +2 -0
  9. package/app/api/agents/image-status/route.ts +94 -85
  10. package/app/api/agents/route.ts +10 -53
  11. package/app/api/agents/token/route.ts +4 -1
  12. package/app/api/auth/check/route.ts +2 -23
  13. package/app/api/auth/login/route.ts +3 -35
  14. package/app/api/auth/logout/route.ts +1 -1
  15. package/app/api/auth/status/route.ts +2 -21
  16. package/app/api/config/env/route.ts +7 -3
  17. package/app/api/config/restart/route.ts +2 -0
  18. package/app/api/containers/route.ts +3 -34
  19. package/app/api/crons/route.ts +2 -0
  20. package/app/api/debug/route.ts +2 -0
  21. package/app/api/envcheck/route.ts +3 -1
  22. package/app/api/gateway/agent/route.ts +8 -6
  23. package/app/api/gateway/provider/route.ts +43 -5
  24. package/app/api/gateway/route.ts +104 -136
  25. package/app/api/gateway/sync.ts +12 -15
  26. package/app/api/models/route.ts +2 -0
  27. package/app/api/provider/upstream.ts +12 -3
  28. package/app/api/setup/agent-image/route.ts +8 -3
  29. package/app/api/setup/password/route.ts +32 -37
  30. package/app/api/setup/restart/route.ts +8 -2
  31. package/app/api/skills/delete/route.js +1 -1
  32. package/app/api/skills/promote/route.js +1 -1
  33. package/app/api/skills/route.js +89 -97
  34. package/app/api/skills/save/route.js +18 -5
  35. package/app/api/update-check/route.ts +4 -23
  36. package/app/api/wizard/complete/route.ts +7 -3
  37. package/app/api/wizard/reset/route.ts +8 -3
  38. package/app/api/wizard/status/route.ts +8 -5
  39. package/app/components/AuthGuard.tsx +29 -8
  40. package/app/components/Skeleton.tsx +71 -0
  41. package/app/components/ui/ModalityIcons.tsx +157 -0
  42. package/app/components/ui/index.ts +1 -0
  43. package/app/crons/PageClient.tsx +32 -7
  44. package/app/gateway/PageClient.tsx +104 -16
  45. package/bin/rev4a.js +35 -24
  46. package/docs/ARCHITECTURE.md +31 -14
  47. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  48. package/docs/REV4A.md +36 -6
  49. package/docs/dev/API-REFERENCE.md +13 -8
  50. package/docs/dev/GATEWAY.md +42 -1
  51. package/docs/dev/PROVIDERS.md +4 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  53. package/instrumentation.ts +18 -0
  54. package/lib/agent-setup.ts +8 -9
  55. package/lib/buildAgentImage.ts +5 -1
  56. package/lib/channelManager.ts +23 -16
  57. package/lib/db.ts +0 -10
  58. package/lib/docker-exec.ts +195 -0
  59. package/lib/docker-socket.ts +121 -0
  60. package/lib/docker-utils.ts +22 -48
  61. package/lib/openclaw-cron.ts +81 -48
  62. package/lib/openrouter-pricing.ts +184 -0
  63. package/lib/rev4a-auth.d.ts +4 -1
  64. package/lib/rev4a-auth.js +68 -20
  65. package/lib/rev4a-paths.ts +19 -0
  66. package/model-pricing.json +50 -50
  67. package/models.config.json +2 -2
  68. package/package.json +4 -1
  69. package/proxy.ts +68 -0
  70. package/scripts/refresh-model-pricing.mjs +171 -0
  71. package/lib/auth.ts +0 -28
  72. package/lib/requireAuth.tsx +0 -38
@@ -12,6 +12,7 @@ import { syncAllAgents } 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';
15
+ import { getLivePricing } from '@/lib/openrouter-pricing';
15
16
  import { requireAuthJWT } from '@/lib/rev4a-auth';
16
17
 
17
18
  export const dynamic = 'force-dynamic';
@@ -136,8 +137,37 @@ function restartService(): { stdout: string; stderr: string } {
136
137
  /* ------------------------------------------------------------------ */
137
138
 
138
139
  export async function GET(request: NextRequest) {
139
- const allModels = loadModelsConfig();
140
+ const denied = await requireAuthJWT(request);
141
+ if (denied) return denied;
140
142
  const providerKeys = readProviderKeys();
143
+
144
+ // ?summary=1 — just "which providers have a key", no models, no pricing.
145
+ // The full response awaits live pricing from OpenRouter, a network round trip
146
+ // that costs ~2.4s cold. Callers that only need the boolean (the agents page,
147
+ // to decide whether to warn about a missing provider) must not pay for it, and
148
+ // must not be left guessing while it's in flight.
149
+ if (request.nextUrl.searchParams.get('summary') === '1') {
150
+ return NextResponse.json({
151
+ providers: Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => ({
152
+ provider: key,
153
+ label: cfg.label,
154
+ configured: !!providerKeys[key],
155
+ })),
156
+ });
157
+ }
158
+
159
+ const allModels = loadModelsConfig();
160
+
161
+ // Live prices overlaid on the static file, which drifts. Fetched regardless
162
+ // of whether OpenRouter is configured: the catalogue is public, and prices
163
+ // are most useful precisely to someone still deciding whether to enable it.
164
+ // Best-effort — an empty map means the static values stand in.
165
+ //
166
+ // Modality deliberately stays on the curated value: sync.ts provisions
167
+ // containers from it, so taking a different one upstream would advertise
168
+ // inputs the agent was never configured to accept.
169
+ const livePricing = await getLivePricing(allModels.map((m) => m.id));
170
+
141
171
  const providers = Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => {
142
172
  const providerModels = allModels.filter((m) => m.provider === key);
143
173
  return {
@@ -145,8 +175,14 @@ export async function GET(request: NextRequest) {
145
175
  baseUrl: cfg.baseUrl, docsUrl: cfg.docsUrl,
146
176
  models: providerModels.map((m) => {
147
177
  const entry: Record<string, unknown> = { id: m.id, name: m.name, enabled: m.enabled };
148
- const p = getPricing(m.id);
149
- if (p) entry.pricing = p;
178
+ const livePrice = livePricing.get(m.id);
179
+ if (livePrice) {
180
+ entry.pricing = livePrice;
181
+ entry.pricingLive = true;
182
+ } else {
183
+ const staticPrice = getPricing(m.id);
184
+ if (staticPrice) entry.pricing = staticPrice;
185
+ }
150
186
  if (m.modality) entry.modality = m.modality;
151
187
  return entry;
152
188
  }),
@@ -157,7 +193,8 @@ export async function GET(request: NextRequest) {
157
193
  }
158
194
 
159
195
  export async function POST(request: NextRequest) {
160
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
196
+ const denied = await requireAuthJWT(request);
197
+ if (denied) return denied;
161
198
  try {
162
199
  const body: { provider?: string; apiKey?: string; _syncOnly?: boolean } = await request.json();
163
200
 
@@ -189,7 +226,8 @@ export async function POST(request: NextRequest) {
189
226
  }
190
227
 
191
228
  export async function PUT(request: NextRequest) {
192
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
229
+ const denied = await requireAuthJWT(request);
230
+ if (denied) return denied;
193
231
  try {
194
232
  const body: { modelId?: string; enabled?: boolean } = await request.json();
195
233
  const { modelId, enabled } = body;
@@ -2,23 +2,42 @@ import { type NextRequest } from "next/server";
2
2
  /**
3
3
  * Gateway Status API
4
4
  *
5
- * Aggregates live model/provider state from Rev4a agent containers.
6
- * Only queries containers with AGENT_ID label — no hardcoded container names.
5
+ * Reports the per-agent model settings the Gateway page needs to render its
6
+ * agent cards and pre-fill the "edit default model" form. Only containers with
7
+ * an AGENT_ID label are considered — no hardcoded container names.
7
8
  *
8
9
  * GET /api/gateway
10
+ *
11
+ * This used to shell out to the OpenClaw CLI inside every container, twice:
12
+ * `openclaw models status --json` (~5.7s) and `openclaw models list --json`
13
+ * (~4.4s), serially, with execSync — so ~10s per agent with the event loop
14
+ * frozen throughout, and roughly 70s on a seven-agent host.
15
+ *
16
+ * Both were avoidable. `models list` produced a model catalogue and alias map
17
+ * that no line of the UI ever read: the page takes its model list from
18
+ * /api/gateway/provider, which reads local files. And `models status` returned,
19
+ * for the two fields actually used, exactly what sits in the agent's own
20
+ * openclaw.json — verified field by field against a live agent. So the CLI was
21
+ * being booted for five seconds to read a file that `cat` returns in 260ms.
22
+ *
23
+ * The response shape is unchanged so the page keeps working untouched; the
24
+ * fields that came only from the discarded CLI output are now reported empty
25
+ * rather than computed at great cost and thrown away.
9
26
  */
10
27
  import { NextResponse } from 'next/server';
11
- import { execSync } from 'child_process';
28
+ import { execFile } from 'child_process';
29
+ import { promisify } from 'util';
12
30
  import { requireAuthJWT } from '@/lib/rev4a-auth';
31
+ import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
32
+
33
+ const execFileAsync = promisify(execFile);
13
34
 
14
35
  export const dynamic = 'force-dynamic';
15
36
 
16
- interface GatewayModel {
17
- key: string;
18
- provider: string;
19
- model: string;
20
- available: boolean;
21
- }
37
+ /** Agents queried at once. Each is a `docker exec … cat`, cheap but still a process. */
38
+ const AGENT_QUERY_CONCURRENCY = 8;
39
+
40
+ const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
22
41
 
23
42
  interface GatewayProvider {
24
43
  provider: string;
@@ -40,158 +59,105 @@ interface GatewayAgentStatus {
40
59
  error?: string;
41
60
  }
42
61
 
43
- interface ApiKeyStatus {
44
- provider: string;
45
- configured: boolean;
46
- }
47
-
48
- /** Run a command inside a Docker container */
49
- function dockerExec(container: string, cmd: string, timeout = 15000): string {
50
- try {
51
- return execSync(
52
- `docker exec ${container} sh -c ${JSON.stringify(cmd)}`,
53
- { timeout, maxBuffer: 1024 * 1024, encoding: 'utf-8' },
54
- );
55
- } catch {
56
- return '';
57
- }
62
+ interface AgentRef {
63
+ agentId: string;
64
+ containerName: string;
65
+ state: string;
58
66
  }
59
67
 
60
- /** Get Docker containers with AGENT_ID label — only Rev4a-managed agents */
61
- function getAgents(): { agentId: string; containerName: string; state: string }[] {
68
+ /**
69
+ * Containers carrying an AGENT_ID label, with the label read straight out of
70
+ * `docker ps` rather than a follow-up `docker inspect` per container.
71
+ */
72
+ async function getAgents(): Promise<AgentRef[]> {
62
73
  try {
63
- const raw = execSync(
64
- `docker ps --filter "label=AGENT_ID" --format '{{.Label "AGENT_ID"}}|{{.Names}}|{{.State}}'`,
65
- { timeout: 5000, maxBuffer: 64 * 1024, encoding: 'utf-8' },
66
- ).trim();
67
- if (!raw) return [];
68
- return raw.split('\n').map((line) => {
69
- const [agentId, containerName, state] = line.split('|');
70
- return { agentId, containerName, state };
71
- });
74
+ const { stdout } = await execFileAsync('docker', [
75
+ 'ps', '--filter', 'label=AGENT_ID',
76
+ '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}\t{{.State}}',
77
+ ], { encoding: 'utf-8', timeout: 5000 });
78
+
79
+ return stdout.trim().split('\n')
80
+ .map((line) => {
81
+ const [agentId, containerName, state] = line.split('\t');
82
+ if (!containerName?.trim()) return null;
83
+ return {
84
+ agentId: agentId?.trim() || containerName.trim(),
85
+ containerName: containerName.trim(),
86
+ state: state?.trim() || 'unknown',
87
+ };
88
+ })
89
+ .filter(Boolean) as AgentRef[];
72
90
  } catch {
73
91
  return [];
74
92
  }
75
93
  }
76
94
 
77
- /** Query an agent container for model status */
78
- function getAgentStatus(agent: { agentId: string; containerName: string; state: string }): GatewayAgentStatus | null {
79
- const raw = dockerExec(agent.containerName, 'openclaw models status --json', 8000);
80
- if (!raw) return null;
81
- try {
82
- const data = JSON.parse(raw);
83
- const authProviders: Record<string, unknown>[] = data.auth?.providers ?? [];
84
- const providers: GatewayProvider[] = authProviders.map((p: Record<string, unknown>) => ({
85
- provider: String(p.provider ?? ''),
86
- kind: String((p.effective as Record<string, unknown>)?.kind ?? ''),
87
- detail: String((p.effective as Record<string, unknown>)?.detail ?? ''),
88
- profiles: Number((p.profiles as Record<string, unknown>)?.count ?? 0),
89
- labels: (p.profiles as Record<string, unknown>)?.labels as string[] ?? [],
90
- }));
91
-
92
- const aliasesRaw = data.aliases ?? {};
93
- const aliases: Record<string, string> = {};
94
- for (const [k, v] of Object.entries(aliasesRaw)) {
95
- aliases[k] = String(v);
96
- }
95
+ /**
96
+ * Read an agent's model settings out of its openclaw.json.
97
+ *
98
+ * `agents.defaults.model` is the same value `openclaw models status` reports as
99
+ * `defaultModel`/`fallbacks`. The CLI additionally resolves an `imageModel`
100
+ * default when the config sets none, but the base image defines no such default
101
+ * (verified: `imageModel: null`), and agent creation always writes an explicit
102
+ * model — so there is no case where the file and the CLI would disagree.
103
+ */
104
+ async function readAgentStatus(agent: AgentRef): Promise<GatewayAgentStatus> {
105
+ const raw = await dockerExecNoFail(
106
+ agent.containerName,
107
+ ['cat', AGENT_CONFIG_PATH],
108
+ { timeoutMs: 8000 },
109
+ );
110
+
111
+ const base: GatewayAgentStatus = {
112
+ agentId: agent.agentId,
113
+ containerName: agent.containerName,
114
+ state: agent.state,
115
+ defaultModel: '',
116
+ fallbacks: [],
117
+ allowed: [],
118
+ aliases: {},
119
+ providers: [],
120
+ };
121
+
122
+ if (!raw) return { ...base, error: 'config unreadable' };
97
123
 
124
+ try {
125
+ const config = JSON.parse(raw) as Record<string, any>;
126
+ const model = config?.agents?.defaults?.model ?? {};
98
127
  return {
99
- agentId: agent.agentId,
100
- containerName: agent.containerName,
101
- state: agent.state,
102
- defaultModel: String(data.defaultModel ?? ''),
103
- fallbacks: (data.fallbacks ?? []).map(String),
104
- allowed: (data.allowed ?? []).map(String),
105
- aliases,
106
- providers,
128
+ ...base,
129
+ defaultModel: typeof model.primary === 'string' ? model.primary : '',
130
+ fallbacks: Array.isArray(model.fallbacks) ? model.fallbacks.map(String) : [],
107
131
  };
108
132
  } catch {
109
- return null;
110
- }
111
- }
112
-
113
- /** Get available models from an agent */
114
- function getAgentModels(container: string): GatewayModel[] {
115
- const raw = dockerExec(container, 'openclaw models list --json', 8000);
116
- if (!raw) return [];
117
- try {
118
- const parsed = JSON.parse(raw);
119
- const models: Record<string, unknown>[] = parsed.models ?? parsed ?? [];
120
- return models
121
- .filter((m: Record<string, unknown>) => m.available !== false && !m.missing)
122
- .map((m: Record<string, unknown>) => ({
123
- key: String(m.key ?? ''),
124
- provider: String(m.key ?? '').split('/')[0] || 'unknown',
125
- model: String(m.name ?? String(m.key ?? '').split('/').slice(1).join('/')),
126
- available: m.available !== false,
127
- }));
128
- } catch {
129
- return [];
133
+ return { ...base, error: 'config unparseable' };
130
134
  }
131
135
  }
132
136
 
133
137
  export async function GET(request: NextRequest): Promise<NextResponse> {
138
+ const denied = await requireAuthJWT(request);
139
+ if (denied) return denied;
134
140
  try {
135
141
  const timestamp = Date.now();
136
142
 
137
- // Only Rev4a-managed agents (AGENT_ID label)
138
- const agentsRaw = getAgents();
139
- const agents = agentsRaw
140
- .map((a) => getAgentStatus(a))
141
- .filter(Boolean) as GatewayAgentStatus[];
142
-
143
- // Aggregate models from all agents (deduplicated by key)
144
- const modelMap = new Map<string, GatewayModel>();
145
- for (const agent of agents) {
146
- for (const m of getAgentModels(agent.containerName)) {
147
- if (!modelMap.has(m.key)) modelMap.set(m.key, m);
148
- }
149
- }
150
- const allModels = Array.from(modelMap.values());
151
- const availableModels = allModels.filter((m) => m.available);
152
-
153
- // Aggregate aliases from all agents
154
- const combinedAliases: Record<string, string[]> = {};
155
- for (const agent of agents) {
156
- for (const [alias, model] of Object.entries(agent.aliases)) {
157
- if (!combinedAliases[alias]) combinedAliases[alias] = [];
158
- if (!combinedAliases[alias].includes(model)) {
159
- combinedAliases[alias].push(model);
160
- }
161
- }
162
- }
143
+ const refs = await getAgents();
144
+ const agents = await mapWithConcurrency(refs, AGENT_QUERY_CONCURRENCY, readAgentStatus);
163
145
 
164
146
  // First agent is the "primary" for core model settings
165
147
  const primaryAgent = agents[0] ?? null;
166
148
 
167
- // API keys from the first agent's providers
168
- const apiKeys: ApiKeyStatus[] = [];
169
- if (primaryAgent) {
170
- for (const p of primaryAgent.providers) {
171
- // We can't query apiKey presence from openclaw models status (it's redacted).
172
- // Mark as configured if the provider appears in the auth list.
173
- apiKeys.push({
174
- provider: p.provider,
175
- configured: p.profiles > 0,
176
- });
177
- }
178
- }
179
-
180
- // Models by provider
181
- const byProvider = allModels.reduce<Record<string, number>>((acc, m) => {
182
- acc[m.provider] = (acc[m.provider] ?? 0) + 1;
183
- return acc;
184
- }, {});
185
-
186
149
  return NextResponse.json({
187
150
  timestamp,
188
151
  gateway: 'Rev4a Model Gateway',
189
152
  status: 'online',
153
+ // The model catalogue lives in /api/gateway/provider, which reads the
154
+ // bundled config plus user overrides. Recomputing it per agent here cost
155
+ // 4.4s each and was never read.
190
156
  models: {
191
- total: allModels.length,
192
- available: availableModels.length,
193
- byProvider: Object.entries(byProvider).map(([provider, count]) => ({ provider, count })),
194
- list: allModels,
157
+ total: 0,
158
+ available: 0,
159
+ byProvider: [],
160
+ list: [],
195
161
  },
196
162
  agents: {
197
163
  total: agents.length,
@@ -205,16 +171,18 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
205
171
  providers: a.providers,
206
172
  })),
207
173
  },
208
- aliases: combinedAliases,
174
+ aliases: {},
209
175
  coreModel: primaryAgent
210
176
  ? {
211
177
  defaultModel: primaryAgent.defaultModel,
212
178
  fallbacks: primaryAgent.fallbacks,
213
179
  }
214
180
  : null,
181
+ // Provider credential state comes from /api/gateway/provider (`configured`
182
+ // per provider), which reads provider-keys.json directly.
215
183
  apiKeys: {
216
- configured: apiKeys.filter((k) => k.configured).map((k) => k.provider),
217
- all: apiKeys,
184
+ configured: [],
185
+ all: [],
218
186
  },
219
187
  });
220
188
  } catch (e: unknown) {
@@ -79,16 +79,6 @@ function writeContainerJson(container: string, remotePath: string, data: Record<
79
79
  );
80
80
  }
81
81
 
82
- function restartContainerGateway(container: string): string | null {
83
- try {
84
- execSync(`docker exec ${container} openclaw gateway restart`, { timeout: 15000, maxBuffer: 64 * 1024 });
85
- return null;
86
- } catch (e: unknown) {
87
- const err = e as { stdout?: string; stderr?: string; message?: string };
88
- return err.stderr?.toString().trim() || err.message || 'unknown error';
89
- }
90
- }
91
-
92
82
  /* ------------------------------------------------------------------ */
93
83
  /* Sync functions */
94
84
  /* ------------------------------------------------------------------ */
@@ -181,9 +171,16 @@ function syncContainer(
181
171
 
182
172
  writeContainerJson(container, remotePath, config);
183
173
 
184
- const gwError = restartContainerGateway(container);
185
- if (gwError) {
186
- return `${container}: file updated but gateway restart failed: ${gwError}`;
187
- }
188
- return `${container}: synced + restarted OK`;
174
+ // No gateway restart: this only writes models.providers.rev4a, and the Gateway
175
+ // watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
176
+ // so out loud — "Change will apply without restarting the gateway" — and its
177
+ // docs put `models` in the no-restart column, with `gateway.reload` defaulting
178
+ // to `hybrid`, which additionally restarts by itself for the changes that do
179
+ // need it. Verified against a live agent: every agent here leaves
180
+ // `gateway.reload` unset, so they all run that default.
181
+ //
182
+ // The restart used to cost ~7.5s per container, serially — roughly 52s of
183
+ // fully blocked event loop on a seven-agent host, every time a provider key or
184
+ // model toggle was saved, to do something the Gateway had already done.
185
+ return `${container}: synced`;
189
186
  }
@@ -48,6 +48,8 @@ function loadModelsConfig(): ModelsConfigEntry[] {
48
48
 
49
49
  export async function GET(_req: NextRequest): Promise<NextResponse> {
50
50
  const request = _req;
51
+ const denied = await requireAuthJWT(request);
52
+ if (denied) return denied;
51
53
  const allModels = loadModelsConfig();
52
54
  const providerKeys = readProviderKeys();
53
55
  const configured = new Set(Object.keys(providerKeys));
@@ -5,8 +5,6 @@
5
5
  * Used by both /api/provider/v1/chat/completions and /api/assistant
6
6
  * to avoid HTTP loopback (calling localhost from inside Next.js).
7
7
  */
8
- import { detectPublicHost } from '@/lib/docker-utils';
9
-
10
8
  export const dynamic = 'force-dynamic';
11
9
 
12
10
  export interface ProviderEnvConfig {
@@ -127,7 +125,18 @@ export async function callUpstream(
127
125
  }
128
126
 
129
127
  if (provider === 'openrouter') {
130
- headers['HTTP-Referer'] = `http://${detectPublicHost()}:3740`;
128
+ // Attribution only: OpenRouter uses this pair to identify the app on its
129
+ // public leaderboard. It never resolves or contacts the URL, and it has no
130
+ // effect on routing, rate limits, pricing, or whether the call succeeds.
131
+ //
132
+ // This used to be `http://${detectPublicHost()}:3740`, which shelled out to
133
+ // curl synchronously — blocking the whole event loop for ~270ms before every
134
+ // OpenRouter call, and 55 of the 69 models enabled in the catalogue are
135
+ // OpenRouter ones. It also fragmented the attribution it was supposed to
136
+ // provide: every install reported a different host, so each one showed up as
137
+ // a separate app. A per-install address was never the right value here —
138
+ // X-Title, the other half of the same label, was always a constant.
139
+ headers['HTTP-Referer'] = 'https://rev4a.app';
131
140
  headers['X-Title'] = 'Rev4a Gateway';
132
141
  }
133
142
 
@@ -12,9 +12,10 @@
12
12
  * Only one download at a time — returns 409 if already running.
13
13
  * Cleans up the docker process if the client disconnects.
14
14
  */
15
- import { NextResponse } from 'next/server';
15
+ import { NextResponse, type NextRequest } from 'next/server';
16
16
  import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
17
17
  import { execSync } from 'child_process';
18
+ import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
18
19
 
19
20
  export const dynamic = 'force-dynamic';
20
21
 
@@ -30,7 +31,9 @@ function dockerOk(): boolean {
30
31
  }
31
32
  }
32
33
 
33
- export async function GET(): Promise<NextResponse> {
34
+ export async function GET(request: NextRequest): Promise<NextResponse> {
35
+ const denied = await requireAuthIfConfigured(request);
36
+ if (denied) return denied;
34
37
  if (!dockerOk()) {
35
38
  return NextResponse.json({ exists: false });
36
39
  }
@@ -55,7 +58,9 @@ export async function GET(): Promise<NextResponse> {
55
58
  }
56
59
  }
57
60
 
58
- export async function POST(): Promise<Response> {
61
+ export async function POST(request: NextRequest): Promise<Response> {
62
+ const denied = await requireAuthIfConfigured(request);
63
+ if (denied) return denied;
59
64
  if (getIsDownloading()) {
60
65
  return new Response(
61
66
  JSON.stringify({ error: 'A download is already in progress' }),
@@ -15,6 +15,7 @@ import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import * as crypto from 'crypto';
17
17
  import { ENV_FILE } from '@/lib/rev4a-paths';
18
+ import { isConfigured } from '@/lib/rev4a-auth';
18
19
 
19
20
  export const dynamic = 'force-dynamic';
20
21
  export const runtime = 'nodejs';
@@ -23,21 +24,33 @@ function generateSecret(len: number): string {
23
24
  return crypto.randomBytes(len).toString('base64url').slice(0, len);
24
25
  }
25
26
 
27
+ /**
28
+ * True only if an actual assignment line for `key` exists — a commented-out
29
+ * or incidental mention (`# old REV4A_TOKEN=…`) does not count. A substring
30
+ * check here would report the key as present and skip generating it, leaving
31
+ * the instance without a usable secret.
32
+ */
33
+ function hasEnvKey(content: string, key: string): boolean {
34
+ return content.split('\n').some((line) => line.trimStart().startsWith(`${key}=`));
35
+ }
36
+
37
+ /** Replace the assignment line for `key`, or append one if none exists. */
38
+ function upsertEnvLine(content: string, key: string, value: string): string {
39
+ const lines = content.split('\n');
40
+ let replaced = false;
41
+ const updated = lines.map((line) => {
42
+ if (!replaced && line.trimStart().startsWith(`${key}=`)) {
43
+ replaced = true;
44
+ return `${key}=${value}`;
45
+ }
46
+ return line;
47
+ });
48
+ if (!replaced) updated.push(`${key}=${value}`);
49
+ return updated.join('\n');
50
+ }
51
+
26
52
  export async function POST(request: Request): Promise<NextResponse> {
27
- // Check both env and .env file (same fallback pattern as login/status routes)
28
- let currentPassword = process.env.REV4A_PASSWORD;
29
- if (typeof currentPassword !== 'string' || currentPassword.length === 0) {
30
- try {
31
- if (fs.existsSync(ENV_FILE)) {
32
- const content = fs.readFileSync(ENV_FILE, 'utf-8');
33
- const match = content.match(/^REV4A_PASSWORD=(.+)$/m);
34
- if (match && match[1].trim()) {
35
- currentPassword = match[1].trim();
36
- }
37
- }
38
- } catch { /* ignore */ }
39
- }
40
- if (typeof currentPassword === 'string' && currentPassword.length > 0) {
53
+ if (isConfigured()) {
41
54
  return NextResponse.json(
42
55
  { error: 'Password is already configured. Use the Config page to change it.' },
43
56
  { status: 409 },
@@ -72,30 +85,12 @@ export async function POST(request: Request): Promise<NextResponse> {
72
85
  content = fs.readFileSync(ENV_FILE, 'utf-8');
73
86
  }
74
87
 
75
- // Ensure password line
76
- if (content.includes('REV4A_PASSWORD=')) {
77
- content = content
78
- .split('\n')
79
- .map((line) =>
80
- line.startsWith('REV4A_PASSWORD=')
81
- ? 'REV4A_PASSWORD=' + body.password
82
- : line,
83
- )
84
- .join('\n');
85
- } else {
86
- content += '\nREV4A_PASSWORD=' + body.password + '\n';
88
+ content = upsertEnvLine(content, 'REV4A_PASSWORD', body.password);
89
+ if (!hasEnvKey(content, 'REV4A_JWT_SECRET')) {
90
+ content = upsertEnvLine(content, 'REV4A_JWT_SECRET', generateSecret(64));
87
91
  }
88
-
89
- // Generate JWT secret if missing
90
- if (!content.includes('REV4A_JWT_SECRET=')) {
91
- const secret = generateSecret(64);
92
- content += '\nREV4A_JWT_SECRET=' + secret + '\n';
93
- }
94
-
95
- // Generate API token if missing
96
- if (!content.includes('REV4A_TOKEN=')) {
97
- const token = generateSecret(32);
98
- content += '\nREV4A_TOKEN=' + token + '\n';
92
+ if (!hasEnvKey(content, 'REV4A_TOKEN')) {
93
+ content = upsertEnvLine(content, 'REV4A_TOKEN', generateSecret(32));
99
94
  }
100
95
 
101
96
  fs.writeFileSync(ENV_FILE, content.trim() + '\n', { encoding: 'utf-8', mode: 0o600 });
@@ -12,15 +12,21 @@
12
12
  * 2. Otherwise send SIGTERM to the current process. The parent process
13
13
  * manager (npx rev4a all or a shell wrapper) should restart it.
14
14
  */
15
- import { NextResponse } from 'next/server';
15
+ import { NextResponse, type NextRequest } from 'next/server';
16
16
  import * as fs from 'fs';
17
17
  import { execSync } from 'child_process';
18
18
  import { ENV_FILE } from '@/lib/rev4a-paths';
19
+ import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
19
20
 
20
21
  export const dynamic = 'force-dynamic';
21
22
  export const runtime = 'nodejs';
22
23
 
23
- export async function POST(): Promise<NextResponse> {
24
+ export async function POST(request: NextRequest): Promise<NextResponse> {
25
+ // Open only during initial setup (before a password exists). Once
26
+ // configured, this triggers a production service restart and must
27
+ // require a valid session like every other privileged route.
28
+ const denied = await requireAuthIfConfigured(request);
29
+ if (denied) return denied;
24
30
  try {
25
31
  if (!fs.existsSync(ENV_FILE)) {
26
32
  return NextResponse.json({ error: '.env file not found' }, { status: 500 });
@@ -3,8 +3,8 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+ import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
6
7
 
7
- const SHARED_SKILLS_DIR = '/docker/shared-skills';
8
8
 
9
9
  /**
10
10
  * DELETE /api/skills/delete
@@ -3,8 +3,8 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { execSync, execFileSync } from 'child_process';
5
5
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+ import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
6
7
 
7
- const SHARED_SKILLS_DIR = '/docker/shared-skills';
8
8
 
9
9
  /**
10
10
  * POST /api/skills/promote