@flame0510/project-aether 1.1.12 → 1.1.13

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 (59) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +84 -22
  3. package/app/api/agents/create/route.ts +15 -3
  4. package/app/api/agents/download-image/route.ts +2 -0
  5. package/app/api/agents/image-status/route.ts +2 -0
  6. package/app/api/agents/route.ts +1 -36
  7. package/app/api/agents/token/route.ts +4 -1
  8. package/app/api/auth/check/route.ts +2 -23
  9. package/app/api/auth/login/route.ts +3 -35
  10. package/app/api/auth/logout/route.ts +1 -1
  11. package/app/api/auth/status/route.ts +2 -21
  12. package/app/api/config/env/route.ts +7 -3
  13. package/app/api/config/restart/route.ts +2 -0
  14. package/app/api/containers/route.ts +3 -34
  15. package/app/api/crons/route.ts +2 -0
  16. package/app/api/debug/route.ts +2 -0
  17. package/app/api/envcheck/route.ts +3 -1
  18. package/app/api/gateway/provider/route.ts +43 -5
  19. package/app/api/gateway/route.ts +2 -0
  20. package/app/api/models/route.ts +2 -0
  21. package/app/api/setup/agent-image/route.ts +8 -3
  22. package/app/api/setup/password/route.ts +32 -37
  23. package/app/api/setup/restart/route.ts +8 -2
  24. package/app/api/skills/delete/route.js +1 -1
  25. package/app/api/skills/promote/route.js +1 -1
  26. package/app/api/skills/route.js +1 -1
  27. package/app/api/skills/save/route.js +18 -5
  28. package/app/api/update-check/route.ts +4 -23
  29. package/app/api/wizard/complete/route.ts +7 -3
  30. package/app/api/wizard/reset/route.ts +8 -3
  31. package/app/api/wizard/status/route.ts +8 -5
  32. package/app/components/AuthGuard.tsx +29 -8
  33. package/app/components/ui/ModalityIcons.tsx +157 -0
  34. package/app/components/ui/index.ts +1 -0
  35. package/app/gateway/PageClient.tsx +104 -16
  36. package/bin/rev4a.js +35 -24
  37. package/docs/ARCHITECTURE.md +31 -13
  38. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  39. package/docs/REV4A.md +36 -6
  40. package/docs/dev/API-REFERENCE.md +9 -4
  41. package/docs/dev/GATEWAY.md +42 -1
  42. package/docs/dev/PROVIDERS.md +4 -3
  43. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  44. package/instrumentation.ts +18 -0
  45. package/lib/agent-setup.ts +8 -9
  46. package/lib/buildAgentImage.ts +5 -1
  47. package/lib/db.ts +0 -10
  48. package/lib/docker-socket.ts +121 -0
  49. package/lib/openrouter-pricing.ts +184 -0
  50. package/lib/rev4a-auth.d.ts +4 -1
  51. package/lib/rev4a-auth.js +68 -20
  52. package/lib/rev4a-paths.ts +19 -0
  53. package/model-pricing.json +50 -50
  54. package/models.config.json +2 -2
  55. package/package.json +4 -1
  56. package/proxy.ts +68 -0
  57. package/scripts/refresh-model-pricing.mjs +171 -0
  58. package/lib/auth.ts +0 -28
  59. package/lib/requireAuth.tsx +0 -38
@@ -227,6 +227,11 @@ Updates model config for a single agent container (primary model only).
227
227
 
228
228
  Returns current provider configuration state.
229
229
 
230
+ Add `?summary=1` for a cheap variant — `{ provider, label, configured }` only,
231
+ skipping the live-pricing fetch. Used by the agents page, which needs nothing
232
+ more than "is any provider configured" and shouldn't block on a network call to
233
+ OpenRouter to find out.
234
+
230
235
  **Response:**
231
236
  ```json
232
237
  {
@@ -237,13 +242,49 @@ Returns current provider configuration state.
237
242
  "configured": true,
238
243
  "baseUrl": "https://api.deepseek.com",
239
244
  "models": [
240
- { "id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", "enabled": true }
245
+ {
246
+ "id": "deepseek/deepseek-v4-flash",
247
+ "name": "DeepSeek V4 Flash",
248
+ "enabled": true,
249
+ "pricing": { "input": 0.05, "output": 0.10 },
250
+ "pricingLive": true,
251
+ "modality": "text->text"
252
+ }
241
253
  ]
242
254
  }
243
255
  ]
244
256
  }
245
257
  ```
246
258
 
259
+ `pricing` is USD per 1M tokens. `-1 / -1` means the model routes dynamically
260
+ (OpenRouter Auto) — the UI renders "Dynamic" and shows "varies" for
261
+ capabilities rather than concrete icons.
262
+
263
+ #### Live pricing
264
+
265
+ `model-pricing.json` is maintained by hand and drifts — 16 of 79 OpenRouter
266
+ entries were wrong when this was added, one understating the real cost ~8x.
267
+ `lib/openrouter-pricing.ts` overlays the live catalogue on top of it and sets
268
+ `pricingLive: true` on the models it covers, which the UI marks with a `LIVE`
269
+ pill.
270
+
271
+ - Endpoint: `GET https://openrouter.ai/api/v1/models` — public, no API key,
272
+ CDN-cached, never billed.
273
+ - Scope: **only** models whose provider is `openrouter`. The same model sold
274
+ direct (openai, anthropic, google…) has different rates, so applying
275
+ OpenRouter's price there would show the wrong vendor's number.
276
+ - Cache mirrors what the endpoint itself declares: `max-age=300` for
277
+ freshness, `stale-if-error=3600` as the upper bound past which a stale
278
+ entry is dropped in favour of the static file. Failures back off for a
279
+ minute instead of retrying on every request.
280
+ - Skipped entirely when OpenRouter isn't configured, so callers that only
281
+ read `configured` (the agents page) don't wait on a network round trip.
282
+ - `modality` is **not** taken from upstream. `sync.ts` provisions agent
283
+ containers from the curated `models.config.json` value, so a different
284
+ capability set in the UI would advertise inputs the container never
285
+ accepted. (`moonshotai/kimi-k3` currently differs: `text+image` curated
286
+ vs `text+image+video` upstream.)
287
+
247
288
  ---
248
289
 
249
290
  ## Config Page (`/config`)
@@ -218,9 +218,10 @@ Key points:
218
218
 
219
219
  ## Security Notes
220
220
 
221
- - The `GET /api/vault/provider/key` endpoint is protected by the authentication
222
- middleware (browser cookie or bearer token). Only authenticated users can reveal
223
- keys.
221
+ - The `GET /api/vault/provider/key` endpoint calls `requireAuthJWT` directly
222
+ (browser cookie or bearer token) — API routes are never covered by `proxy.ts`
223
+ (it explicitly exempts `/api/*`), so each route must guard itself. Only
224
+ authenticated users can reveal keys.
224
225
  - The UI **must never** log, screenshot, or otherwise persist the revealed key
225
226
  outside the active screen view.
226
227
  - All secrets in documentation, logs, or reports must be masked (e.g. `sk-...ast4`).
@@ -87,7 +87,7 @@ Browse installed OpenClaw plugins. Enable/disable toggle per plugin. Plugins ext
87
87
 
88
88
  ### Skills (`/skills`)
89
89
  Skill registry showing all skills across three sources:
90
- - **Shared** — global skills from `/docker/shared-skills`, available to all agents
90
+ - **Shared** — global skills from `~/.config/rev4a/shared/shared-skills`, available to all agents
91
91
  - **Per-agent** — skills in each agent's workspace (`<workspace>/skills`), shown with agent label
92
92
  - **Bundled** — skills shipped with OpenClaw (read-only)
93
93
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Next.js instrumentation hook — runs once at server startup.
3
+ *
4
+ * Syncs the provider gateway baseUrl to all agent containers on boot.
5
+ * This ensures agents pick up any domain/env changes after a restart,
6
+ * VPS reboot, or crash recovery — no manual Sync button needed.
7
+ */
8
+ export async function register() {
9
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
10
+ try {
11
+ const { syncAllAgents } = await import('./app/api/gateway/sync');
12
+ syncAllAgents();
13
+ } catch {
14
+ // Best-effort — don't block startup if sync fails
15
+ console.warn('[rev4a] Failed to sync agents on startup');
16
+ }
17
+ }
18
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { execSync } from 'child_process';
12
12
  import * as fs from 'fs';
13
+ import { SHARED_SKILLS_DIR, REV4A_RULES_DIR } from '@/lib/rev4a-paths';
13
14
 
14
15
  // ── Volumes ──────────────────────────────────────────────────────────────────
15
16
 
@@ -28,12 +29,12 @@ export interface Rev4aVolume {
28
29
  */
29
30
  export const REV4A_VOLUMES: Rev4aVolume[] = [
30
31
  {
31
- source: '/docker/shared-skills',
32
+ source: SHARED_SKILLS_DIR,
32
33
  target: '/data/.openclaw/shared-skills',
33
34
  mode: 'ro',
34
35
  },
35
36
  {
36
- source: '/docker/rev4a-rules',
37
+ source: REV4A_RULES_DIR,
37
38
  target: '/root/.openclaw/workspace/.rev4a',
38
39
  mode: 'ro',
39
40
  },
@@ -60,14 +61,12 @@ export function getMountFlags(): string[] {
60
61
  * multiple times.
61
62
  */
62
63
  export function ensureHostDirs(): void {
64
+ // Plain mkdir: these now live under the user's own data directory, so no
65
+ // elevation is needed. The previous `sudo mkdir` with a plain fallback was
66
+ // only required because the paths sat at the filesystem root, and it could
67
+ // hang on a password prompt when run non-interactively.
63
68
  for (const vol of REV4A_VOLUMES) {
64
- if (!fs.existsSync(vol.source)) {
65
- try {
66
- execSync(`sudo mkdir -p ${escapeShell(vol.source)}`, { stdio: 'pipe' });
67
- } catch {
68
- execSync(`mkdir -p ${escapeShell(vol.source)}`, { stdio: 'pipe' });
69
- }
70
- }
69
+ fs.mkdirSync(vol.source, { recursive: true });
71
70
  }
72
71
  }
73
72
 
@@ -193,7 +193,11 @@ async function pullFromRegistry(
193
193
 
194
194
  child.on('error', (err: Error) => {
195
195
  appendLog(`Pull process error: ${err.message}`);
196
- releaseLock();
196
+ // Do NOT release the lock here: the caller falls through to
197
+ // localBuild(), which must stay protected. Releasing early let a
198
+ // concurrent request past getIsDownloading() and start a second
199
+ // `docker build` against the same tag. The lock is released by
200
+ // localBuild's own close/error handlers, or by the success path.
197
201
  resolve(false);
198
202
  });
199
203
  });
package/lib/db.ts CHANGED
@@ -1,19 +1,9 @@
1
1
  // Shared DB helpers for API routes
2
2
  import Database from 'better-sqlite3';
3
- import type { NextRequest } from 'next/server';
4
- import { NextResponse } from 'next/server';
5
3
  import { initializeRev4aDb } from './db-bootstrap.mjs';
6
4
  import { DB_FILE } from './rev4a-paths';
7
5
 
8
6
  export const DB_PATH = process.env.REV4A_DB || DB_FILE;
9
- export const TOKEN = process.env.REV4A_TOKEN ?? '';
10
-
11
- export function requireAuth(request: NextRequest): NextResponse | null {
12
- const token = new URL(request.url).searchParams.get('token');
13
- const auth = request.headers.get('authorization');
14
- const ok = token === TOKEN || auth === `Bearer ${TOKEN}`;
15
- return ok ? null : NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16
- }
17
7
 
18
8
  export function ensureDbReady(): void {
19
9
  initializeRev4aDb(DB_PATH);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Docker Engine API over its local unix socket.
3
+ *
4
+ * The socket path is NOT the same everywhere. `/var/run/docker.sock` is the
5
+ * Linux default (and what the VPS uses), but Docker Desktop on macOS puts it
6
+ * at `~/.docker/run/docker.sock` and does not create the /var/run symlink
7
+ * unless the user opts in. Hardcoding the Linux path made every read-only
8
+ * Docker route return an empty list on macOS — agents existed and ran, but
9
+ * the dashboard showed nothing, because creation shells out to the `docker`
10
+ * CLI (which reads the context) while listing went through this socket.
11
+ *
12
+ * Talking to the socket directly is preferred over shelling out for reads:
13
+ * no fork, no shell quoting, no `--format` template to parse — just JSON.
14
+ */
15
+ import * as http from 'http';
16
+ import * as fs from 'fs';
17
+ import * as os from 'os';
18
+ import * as path from 'path';
19
+ import { execFileSync } from 'child_process';
20
+
21
+ let cached: string | null = null;
22
+ let lastMissAt = 0;
23
+
24
+ /**
25
+ * How long a failed resolution is remembered. Long enough that a Docker outage
26
+ * doesn't spawn a `docker context inspect` per request, short enough that a
27
+ * server which started before the Docker daemon picks it up on its own.
28
+ */
29
+ const MISS_TTL_MS = 5000;
30
+
31
+ function fromDockerHostEnv(): string | null {
32
+ const raw = process.env.DOCKER_HOST;
33
+ if (!raw) return null;
34
+ // Only unix sockets are usable here; tcp:// would need a different client.
35
+ if (!raw.startsWith('unix://')) return null;
36
+ return raw.slice('unix://'.length);
37
+ }
38
+
39
+ function fromDockerContext(): string | null {
40
+ try {
41
+ const out = execFileSync(
42
+ 'docker',
43
+ ['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'],
44
+ { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] },
45
+ ).trim();
46
+ return out.startsWith('unix://') ? out.slice('unix://'.length) : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function usable(candidate: string | null): string | null {
53
+ if (!candidate) return null;
54
+ try {
55
+ fs.accessSync(candidate, fs.constants.R_OK | fs.constants.W_OK);
56
+ return candidate;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve the Docker socket, most authoritative source first. Returns null when
64
+ * Docker isn't reachable at all.
65
+ *
66
+ * A success is cached for the life of the process — the path doesn't move. A
67
+ * failure is only cached for MISS_TTL_MS: Rev4a can legitimately start before
68
+ * the Docker daemon is up (systemd ordering, Docker Desktop still booting), and
69
+ * caching that failure permanently would leave every Docker route returning an
70
+ * empty list until someone restarted the server.
71
+ */
72
+ export function resolveDockerSocket(): string | null {
73
+ if (cached) return cached;
74
+ if (Date.now() - lastMissAt < MISS_TTL_MS) return null;
75
+
76
+ const found =
77
+ usable(fromDockerHostEnv()) ??
78
+ usable(fromDockerContext()) ??
79
+ // Docker Desktop (macOS, and Windows with WSL integration)
80
+ usable(path.join(os.homedir(), '.docker', 'run', 'docker.sock')) ??
81
+ // Linux default — the VPS lands here
82
+ usable('/var/run/docker.sock');
83
+
84
+ if (found) cached = found;
85
+ else lastMissAt = Date.now();
86
+
87
+ return found;
88
+ }
89
+
90
+ /** True when a usable Docker socket exists. */
91
+ export function dockerAvailable(): boolean {
92
+ return resolveDockerSocket() !== null;
93
+ }
94
+
95
+ /**
96
+ * GET/POST against the Docker Engine API, resolving the response as JSON.
97
+ * Rejects when Docker is unreachable or the payload isn't JSON.
98
+ */
99
+ export function dockerFetch<T = any>(method: string, apiPath: string): Promise<T> {
100
+ return new Promise((resolve, reject) => {
101
+ const socketPath = resolveDockerSocket();
102
+ if (!socketPath) {
103
+ reject(new Error('Docker socket not found'));
104
+ return;
105
+ }
106
+
107
+ const req = http.request(
108
+ { socketPath, path: apiPath, method, headers: { Host: 'localhost' } },
109
+ (res) => {
110
+ let data = '';
111
+ res.on('data', (chunk) => { data += chunk; });
112
+ res.on('end', () => {
113
+ try { resolve(JSON.parse(data) as T); }
114
+ catch { reject(new Error('Invalid JSON from Docker')); }
115
+ });
116
+ },
117
+ );
118
+ req.on('error', reject);
119
+ req.end();
120
+ });
121
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Live pricing for OpenRouter-hosted models.
3
+ *
4
+ * model-pricing.json is maintained by hand and drifts: at the time this was
5
+ * written, 16 of 79 OpenRouter entries disagreed with the live catalogue —
6
+ * one of them (google/gemini-2.5-flash) understated the real cost by ~8x.
7
+ * The static file stays as the offline fallback; this overlays it when the
8
+ * catalogue is reachable.
9
+ *
10
+ * Scope is deliberately narrow: only models whose provider is `openrouter`.
11
+ * OpenRouter resells models that we ALSO offer direct (openai, anthropic,
12
+ * google, …) at different rates, so applying these prices to a direct
13
+ * provider entry would show the wrong vendor's price.
14
+ *
15
+ * Only pricing is taken from upstream — NOT modality. app/api/gateway/sync.ts
16
+ * provisions agent containers from the curated models.config.json value, so
17
+ * showing a different capability set in the UI would advertise inputs the
18
+ * container was never configured to accept.
19
+ *
20
+ * The endpoint is public (no API key, served from CDN) — calling it costs
21
+ * nothing and is never billed.
22
+ */
23
+
24
+ const CATALOGUE_URL = 'https://openrouter.ai/api/v1/models';
25
+ // The call measures ~300ms against the CDN, so 3s is ~10x headroom. A longer
26
+ // limit wouldn't protect anything — it would just hold the response open when
27
+ // the catalogue is unreachable, and the static file is a fine stand-in.
28
+ const FETCH_TIMEOUT_MS = 3000;
29
+
30
+ // Mirrors the cache policy OpenRouter declares on the endpoint itself:
31
+ // cache-control: public, max-age=300, stale-while-revalidate=3600, stale-if-error=3600
32
+ const TTL_MS = 5 * 60 * 1000; // max-age=300
33
+ const MAX_STALE_MS = 60 * 60 * 1000; // stale-if-error=3600
34
+ // After a failure, don't retry on every single request — an unreachable
35
+ // catalogue would otherwise make each one pay the full abort timeout.
36
+ const RETRY_AFTER_FAILURE_MS = 60 * 1000;
37
+
38
+ export interface LivePricing {
39
+ input: number; // USD per 1M prompt tokens
40
+ output: number; // USD per 1M completion tokens
41
+ }
42
+
43
+ interface OpenRouterModel {
44
+ id: string;
45
+ pricing?: { prompt?: string; completion?: string };
46
+ }
47
+
48
+ interface CacheEntry {
49
+ pricing: Map<string, LivePricing>;
50
+ fetchedAt: number;
51
+ }
52
+
53
+ let cache: CacheEntry | null = null;
54
+ let lastFailureAt = 0;
55
+ let inFlight: Promise<CacheEntry | null> | null = null;
56
+
57
+ /** OpenRouter quotes per-token; the UI works in per-million. */
58
+ function perMillion(raw: string | undefined): number | null {
59
+ if (!raw) return null;
60
+ const n = parseFloat(raw);
61
+ // -1 means "dynamic" (router models); not a real price, leave it to the
62
+ // static file which already encodes it as -1/-1 and renders as "Dynamic".
63
+ if (!Number.isFinite(n) || n < 0) return null;
64
+ return n * 1_000_000;
65
+ }
66
+
67
+ async function fetchCatalogue(): Promise<CacheEntry | null> {
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
70
+ try {
71
+ const res = await fetch(CATALOGUE_URL, {
72
+ signal: controller.signal,
73
+ headers: { accept: 'application/json' },
74
+ });
75
+ if (!res.ok) {
76
+ console.warn(`[openrouter-pricing] catalogue returned HTTP ${res.status}`);
77
+ return null;
78
+ }
79
+
80
+ const body = (await res.json()) as { data?: OpenRouterModel[] };
81
+ if (!Array.isArray(body?.data)) {
82
+ console.warn('[openrouter-pricing] unexpected catalogue shape');
83
+ return null;
84
+ }
85
+
86
+ const pricing = new Map<string, LivePricing>();
87
+ for (const m of body.data) {
88
+ if (!m?.id) continue;
89
+ const input = perMillion(m.pricing?.prompt);
90
+ const output = perMillion(m.pricing?.completion);
91
+ if (input !== null && output !== null) pricing.set(m.id, { input, output });
92
+ }
93
+
94
+ if (pricing.size === 0) return null;
95
+ return { pricing, fetchedAt: Date.now() };
96
+ } catch (e) {
97
+ const reason = e instanceof Error ? e.name : 'unknown';
98
+ console.warn(`[openrouter-pricing] catalogue unreachable (${reason}) — using static prices`);
99
+ return null;
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+
105
+ function isUsable(entry: CacheEntry | null): entry is CacheEntry {
106
+ // Past stale-if-error we stop trusting it entirely and fall back to the
107
+ // static file, rather than labelling day-old numbers as live.
108
+ return !!entry && Date.now() - entry.fetchedAt < MAX_STALE_MS;
109
+ }
110
+
111
+ /**
112
+ * Cached catalogue, refreshed when stale. Concurrent callers share one
113
+ * in-flight request. A failure is remembered so an unreachable catalogue
114
+ * doesn't make every request pay the timeout; the previous entry keeps
115
+ * being served until it exceeds the stale-if-error window.
116
+ */
117
+ async function getCatalogue(): Promise<CacheEntry | null> {
118
+ const fresh = cache && Date.now() - cache.fetchedAt < TTL_MS;
119
+ if (fresh) return cache;
120
+
121
+ if (inFlight) return inFlight;
122
+
123
+ // Backing off after a recent failure — serve what we have, if still usable.
124
+ if (Date.now() - lastFailureAt < RETRY_AFTER_FAILURE_MS) {
125
+ return isUsable(cache) ? cache : null;
126
+ }
127
+
128
+ inFlight = fetchCatalogue()
129
+ .then((result) => {
130
+ if (result) {
131
+ cache = result;
132
+ lastFailureAt = 0;
133
+ } else {
134
+ lastFailureAt = Date.now();
135
+ }
136
+ return isUsable(cache) ? cache : null;
137
+ })
138
+ .finally(() => {
139
+ inFlight = null;
140
+ });
141
+
142
+ return inFlight;
143
+ }
144
+
145
+ /**
146
+ * Map one of our ids to the upstream one.
147
+ *
148
+ * Our catalogue namespaces OpenRouter entries as `openrouter/<upstream id>`,
149
+ * but OpenRouter also publishes its own house models under a literal
150
+ * `openrouter/` vendor prefix (`openrouter/auto`, `openrouter/auto-beta`, …).
151
+ * Stripping unconditionally turns `openrouter/auto` into `auto`, which does
152
+ * not exist upstream — so try the stripped form first and fall back to the
153
+ * id as-is.
154
+ */
155
+ function candidateUpstreamIds(modelId: string): string[] {
156
+ if (!modelId.startsWith('openrouter/')) return [];
157
+ const stripped = modelId.slice('openrouter/'.length);
158
+ return stripped === modelId ? [modelId] : [stripped, modelId];
159
+ }
160
+
161
+ /**
162
+ * Pricing overlay keyed by OUR model ids, for the OpenRouter-hosted subset.
163
+ * Returns an empty map when the catalogue can't be reached — never throws.
164
+ */
165
+ export async function getLivePricing(modelIds: string[]): Promise<Map<string, LivePricing>> {
166
+ const overlay = new Map<string, LivePricing>();
167
+
168
+ const wanted = modelIds.filter((id) => id.startsWith('openrouter/'));
169
+ if (wanted.length === 0) return overlay;
170
+
171
+ const catalogue = await getCatalogue();
172
+ if (!catalogue) return overlay;
173
+
174
+ for (const ourId of wanted) {
175
+ for (const upstreamId of candidateUpstreamIds(ourId)) {
176
+ const price = catalogue.pricing.get(upstreamId);
177
+ if (price) {
178
+ overlay.set(ourId, price);
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ return overlay;
184
+ }
@@ -1,3 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
- export function requireAuth(request: Request): NextResponse | null;
3
2
  export function requireAuthJWT(request: Request): Promise<NextResponse | null>;
3
+ export function requireAuthIfConfigured(request: Request): Promise<NextResponse | null>;
4
+ export function getPassword(): string;
5
+ export function getJwtSecret(): string;
6
+ export function isConfigured(): boolean;
package/lib/rev4a-auth.js CHANGED
@@ -1,36 +1,84 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { jwtVerify } from "jose";
3
+ import * as fs from "fs";
4
+ import { ENV_FILE } from "@/lib/rev4a-paths";
3
5
 
4
- function getExpectedToken() {
5
- const token = process.env.REV4A_TOKEN || process.env.NEXT_PUBLIC_REV4A_TOKEN || '';
6
- if (!token) console.warn('REV4A_TOKEN is not set — auth will reject all requests');
7
- return token;
6
+ // ---------------------------------------------------------------------------
7
+ // Single source of truth for secret resolution. Every route and proxy.ts
8
+ // import these instead of keeping their own env+file-fallback copy — the
9
+ // duplication used to drift (proxy.ts had an env-only version because it
10
+ // used to run on Edge runtime, which can't use fs; now that it runs on
11
+ // Node.js runtime the split serves no purpose and only risks desync).
12
+ //
13
+ // In the supported `rev4a serve` flow the file fallback is rarely hit:
14
+ // bin/rev4a.js injects these into the Next.js child's env, and writeEnvVar()
15
+ // updates process.env in-process. It exists for setups that bypass the CLI
16
+ // (a hand-written systemd unit, `npm run dev` before .env exists).
17
+ // ---------------------------------------------------------------------------
18
+ const PASSWORD_RE = /^REV4A_PASSWORD=(.*)$/m;
19
+ const JWT_SECRET_RE = /^REV4A_JWT_SECRET=(.*)$/m;
20
+
21
+ function readEnvFileValue(re) {
22
+ try {
23
+ if (fs.existsSync(ENV_FILE)) {
24
+ const match = fs.readFileSync(ENV_FILE, "utf-8").match(re);
25
+ if (match && match[1].trim()) return match[1].trim();
26
+ }
27
+ } catch { /* ignore */ }
28
+ return "";
8
29
  }
9
30
 
10
- export function requireAuth(request) {
11
- const auth = request.headers.get("authorization");
12
- if (!auth || auth !== `Bearer ${getExpectedToken()}`) {
13
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14
- }
15
- return null;
31
+ export function getPassword() {
32
+ return process.env.REV4A_PASSWORD || readEnvFileValue(PASSWORD_RE);
16
33
  }
17
34
 
18
- const JWT_SECRET = new TextEncoder().encode(
19
- process.env.REV4A_JWT_SECRET || ''
20
- );
35
+ export function getJwtSecret() {
36
+ return process.env.REV4A_JWT_SECRET || readEnvFileValue(JWT_SECRET_RE);
37
+ }
21
38
 
39
+ export function isConfigured() {
40
+ return getPassword().length > 0;
41
+ }
42
+
43
+ function unauthorized() {
44
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
45
+ }
46
+
47
+ /**
48
+ * Cookie (JWT) auth, falling back to `Authorization: Bearer <REV4A_TOKEN>`.
49
+ * Returns null when authorized, or a 401 response to return as-is.
50
+ */
22
51
  export async function requireAuthJWT(request) {
23
52
  const cookieToken = request.cookies?.get?.("rev4a_token")?.value;
24
- if (cookieToken) {
53
+ const secret = getJwtSecret();
54
+
55
+ // An empty secret would mean verifying against a zero-length HMAC key.
56
+ // jose rejects that, but bail explicitly rather than relying on it.
57
+ if (cookieToken && secret) {
25
58
  try {
26
- await jwtVerify(cookieToken, JWT_SECRET);
59
+ await jwtVerify(cookieToken, new TextEncoder().encode(secret));
27
60
  return null;
28
- } catch {}
61
+ } catch { /* fall through to bearer */ }
62
+ }
63
+
64
+ const expectedToken = process.env.REV4A_TOKEN || process.env.NEXT_PUBLIC_REV4A_TOKEN || "";
65
+ // Without this guard an unset token would make `Bearer ` match an empty
66
+ // expected value. HTTP header normalisation already strips the trailing
67
+ // space so it isn't reachable in practice — the guard makes that safety
68
+ // explicit instead of dependent on it.
69
+ if (expectedToken) {
70
+ const auth = request.headers.get("authorization");
71
+ if (auth === `Bearer ${expectedToken}`) return null;
29
72
  }
30
73
 
31
- const auth = request.headers.get("authorization");
32
- const expectedToken = process.env.REV4A_TOKEN || process.env.NEXT_PUBLIC_REV4A_TOKEN || '';
33
- if (auth === `Bearer ${expectedToken}`) return null;
74
+ return unauthorized();
75
+ }
34
76
 
35
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
77
+ /**
78
+ * For routes that must stay reachable during first-run setup (before any
79
+ * password exists) but must be protected once the instance is configured.
80
+ */
81
+ export async function requireAuthIfConfigured(request) {
82
+ if (!isConfigured()) return null;
83
+ return requireAuthJWT(request);
36
84
  }
@@ -39,6 +39,25 @@ export const AGENTS_TOKEN_FILE = path.join(REV4A_DATA, 'data', 'agents-token.jso
39
39
  /** Provider API keys JSON */
40
40
  export const PROVIDER_KEYS_FILE = path.join(REV4A_DATA, 'data', 'provider-keys.json');
41
41
 
42
+ /* ── Host directories bind-mounted into every agent container ───── */
43
+
44
+ /**
45
+ * These used to live at `/docker/shared-skills` and `/docker/rev4a-rules`.
46
+ * That path can't work on macOS: the root volume is a sealed APFS system
47
+ * volume, so `/docker` cannot be created even as root. It also required
48
+ * `sudo mkdir` on Linux for no real benefit.
49
+ *
50
+ * Under REV4A_DATA they sit inside the user's home, which additionally
51
+ * falls within the directories Docker Desktop shares with its Linux VM by
52
+ * default — so a bind mount from here works with no extra configuration.
53
+ */
54
+
55
+ /** Skills shared across all agents, mounted read-only. */
56
+ export const SHARED_SKILLS_DIR = path.join(REV4A_DATA, 'shared', 'shared-skills');
57
+
58
+ /** Rev4a system rules injected into every agent workspace, mounted read-only. */
59
+ export const REV4A_RULES_DIR = path.join(REV4A_DATA, 'shared', 'rev4a-rules');
60
+
42
61
  /**
43
62
  * Model catalogue — read-only, shipped with the package.
44
63
  * Resolved at module load from the package root (alongside bin/rev4a.js).