@flame0510/project-aether 1.6.2 → 1.8.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/agent-templates/atlas/HEARTBEAT.md +1 -1
  4. package/app/agents/ImageDownloadBanner.tsx +171 -37
  5. package/app/api/agents/download-image/route.ts +29 -5
  6. package/app/api/agents/image-status/route.ts +17 -2
  7. package/app/api/assistant/route.ts +21 -5
  8. package/app/api/auth/login/route.ts +2 -2
  9. package/app/api/metrics/route.ts +126 -23
  10. package/app/api/setup/agent-image/route.ts +6 -4
  11. package/app/api/system-health/route.ts +25 -21
  12. package/app/components/DashboardToolbar.tsx +2 -2
  13. package/app/components/LineageGraphPage.tsx +4 -4
  14. package/app/components/SessionDrawer.tsx +8 -8
  15. package/app/components/Sidebar.tsx +10 -0
  16. package/app/components/Skeleton.tsx +4 -1
  17. package/app/components/SystemCockpit.tsx +83 -3
  18. package/app/components/ui/Meter.tsx +34 -0
  19. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  20. package/app/components/ui/index.ts +2 -0
  21. package/app/globals.css +42 -1
  22. package/app/setup/PageClient.tsx +1 -1
  23. package/app/system/PageClient.tsx +263 -0
  24. package/app/system/SystemSkeleton.tsx +115 -0
  25. package/app/system/loading.tsx +13 -0
  26. package/app/system/page.tsx +5 -0
  27. package/bin/postinstall.js +5 -1
  28. package/daemon.js +274 -214
  29. package/docs/ARCHITECTURE.md +65 -34
  30. package/docs/CONTAINER-TERMINAL.md +17 -8
  31. package/docs/DESIGN-SYSTEM.md +22 -12
  32. package/docs/FRONTEND-ARCHITECTURE.md +8 -4
  33. package/docs/REV4A.md +37 -86
  34. package/docs/dev/API-REFERENCE.md +102 -19
  35. package/docs/dev/DATABASE.md +79 -28
  36. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
  37. package/docs/rag/DATA-FRESHNESS.md +31 -15
  38. package/docs/rag/GLOSSARY.md +8 -5
  39. package/docs/rag/REV4A-OVERVIEW.md +14 -7
  40. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -3
  41. package/lib/agent-images.ts +43 -14
  42. package/lib/buildAgentImage.ts +142 -6
  43. package/lib/db-bootstrap.mjs +0 -11
  44. package/lib/metrics-db.ts +48 -0
  45. package/lib/patterns/sessionPresentation.ts +4 -2
  46. package/lib/rev4a-auth.d.ts +1 -0
  47. package/lib/rev4a-auth.js +18 -2
  48. package/next.config.mjs +9 -1
  49. package/package.json +2 -2
  50. package/scripts/backup.sh +48 -54
  51. package/scripts/check-language.mjs +21 -3
  52. package/scripts/restore.sh +77 -59
@@ -226,6 +226,13 @@ export async function remoteSupportedVersions(): Promise<string[] | null> {
226
226
  /**
227
227
  * Runs `docker <args>`, streaming output lines; rejects with the last error line.
228
228
  *
229
+ * A signal that is already aborted rejects before anything is spawned: an `abort`
230
+ * listener added to an aborted signal never fires, so the process would otherwise run
231
+ * to completion even though its caller has been cancelled.
232
+ *
233
+ * Lines are reassembled across output chunks — a chunk can end mid-line — and `\r` is
234
+ * treated as a line break too, since docker redraws progress with it.
235
+ *
229
236
  * `env` is for container environment on `docker run`: each name is inserted as `-e KEY`
230
237
  * right after the subcommand, and the value reaches docker through its own
231
238
  * environment, so secrets stay out of the process table.
@@ -235,6 +242,10 @@ export function runDocker(
235
242
  opts: { onLine?: (line: string) => void; signal?: AbortSignal; timeoutMs: number; env?: Record<string, string> },
236
243
  ): Promise<void> {
237
244
  return new Promise((resolve, reject) => {
245
+ if (opts.signal?.aborted) {
246
+ reject(new Error(`docker ${args[0]} aborted`));
247
+ return;
248
+ }
238
249
  const envNames = Object.keys(opts.env ?? {});
239
250
  const argv = envNames.length ? [args[0], ...envNames.flatMap((k) => ['-e', k]), ...args.slice(1)] : args;
240
251
  const child = spawn('docker', argv, {
@@ -243,17 +254,28 @@ export function runDocker(
243
254
  });
244
255
  let lastError = '';
245
256
  let firstError = '';
246
- const onData = (isErr: boolean) => (chunk: Buffer) => {
247
- for (const line of chunk.toString().split(/\r?\n/)) {
248
- if (!line.trim()) continue;
249
- if (isErr) {
250
- // Docker prints the cause first and "Run 'docker run --help' …" last; keep
251
- // both ends so the rejection can carry the cause, not the usage pointer.
252
- firstError ||= line.trim();
253
- lastError = line.trim();
254
- }
255
- opts.onLine?.(line);
257
+ const emit = (line: string, isErr: boolean) => {
258
+ if (!line.trim()) return;
259
+ if (isErr) {
260
+ // Docker prints the cause first and "Run 'docker run --help' …" last; keep
261
+ // both ends so the rejection can carry the cause, not the usage pointer.
262
+ firstError ||= line.trim();
263
+ lastError = line.trim();
256
264
  }
265
+ opts.onLine?.(line);
266
+ };
267
+ // The unfinished tail of the last chunk, per stream, completed by the next one.
268
+ const pending = { out: '', err: '' };
269
+ const onData = (isErr: boolean) => (chunk: Buffer) => {
270
+ const key = isErr ? 'err' : 'out';
271
+ const parts = (pending[key] + chunk.toString()).split(/\r\n|\n|\r/);
272
+ pending[key] = parts.pop() ?? '';
273
+ for (const line of parts) emit(line, isErr);
274
+ };
275
+ const flush = () => {
276
+ emit(pending.out, false);
277
+ emit(pending.err, true);
278
+ pending.out = pending.err = '';
257
279
  };
258
280
  child.stdout.on('data', onData(false));
259
281
  child.stderr.on('data', onData(true));
@@ -271,6 +293,7 @@ export function runDocker(
271
293
  reject(err);
272
294
  });
273
295
  child.on('close', (code) => {
296
+ flush();
274
297
  clearTimeout(timer);
275
298
  opts.signal?.removeEventListener('abort', kill);
276
299
  if (code === 0) resolve();
@@ -281,8 +304,9 @@ export function runDocker(
281
304
 
282
305
  /**
283
306
  * Pull `<registry>:<version>` and tag it `openclaw-agent-base:<version>`. The registry
284
- * reference is removed afterwards (an untag, the layers stay), so local images list
285
- * under the version tag only.
307
+ * reference is removed afterwards, so local images list under the version tag only: after
308
+ * a successful tag that is an untag (the layers stay); when the tag failed or was
309
+ * cancelled it is the image's only name, and the pulled image is deleted with it.
286
310
  */
287
311
  export async function pullAgentImage(
288
312
  version: string,
@@ -291,8 +315,13 @@ export async function pullAgentImage(
291
315
  if (!isSupportedVersion(version)) throw new Error(`OpenClaw ${version} is not supported by this Rev4a`);
292
316
  const remote = `${registryRepository()}:${version}`;
293
317
  await runDocker(['pull', remote], { ...opts, timeoutMs: PULL_TIMEOUT_MS });
294
- await runDocker(['tag', remote, localImageRef(version)], { ...opts, timeoutMs: 30_000 });
295
- await runDocker(['rmi', remote], { timeoutMs: 30_000 }).catch(() => {});
318
+ try {
319
+ await runDocker(['tag', remote, localImageRef(version)], { ...opts, timeoutMs: 30_000 });
320
+ } finally {
321
+ // Also when the tag failed or was cancelled: left behind, the registry reference
322
+ // is a full image under a name neither the listing nor retention looks at.
323
+ await runDocker(['rmi', remote], { timeoutMs: 30_000 }).catch(() => {});
324
+ }
296
325
  }
297
326
 
298
327
  /**
@@ -11,8 +11,9 @@
11
11
  * instead — development without registry access. Any other version cannot be built
12
12
  * from here, so its download fails.
13
13
  *
14
- * One download at a time (in-process lock). Output goes to
15
- * /tmp/rev4a-download-<timestamp>.log so it survives page refreshes and modal closes.
14
+ * One download at a time (in-process lock). Docker's output also goes to
15
+ * /tmp/rev4a-download-<timestamp>.log (mode 0600, kept 24 h) for diagnosis — nothing in the
16
+ * UI reads it; the banner gets its percent and status line from `getDownloadProgress()`.
16
17
  */
17
18
  import * as fs from 'fs';
18
19
  import * as path from 'path';
@@ -26,6 +27,36 @@ const BUILD_TIMEOUT_MS = 60 * 60 * 1000;
26
27
  /** The version being downloaded; `pending` while the default version is being resolved. */
27
28
  let downloadingVersion: string | null = null;
28
29
  let downloadLogPath: string | null = null;
30
+ /** Owns the running pull/build process; `cancelDownload()` aborts it. One at a time, like the download itself. */
31
+ let downloadController: AbortController | null = null;
32
+ /** `docker pull` layer ids seen, by state — the source of the real percent below. */
33
+ const pullLayers = new Map<string, 'pending' | 'done'>();
34
+ /** Set once a layer starts transferring: from then on the layer list is complete. */
35
+ let pullTransferStarted = false;
36
+ let downloadPercent: number | null = null;
37
+ /** The latest line of docker's output, shown under the percent (or alone when there is none). */
38
+ let downloadMessage: string | null = null;
39
+
40
+ export interface DownloadOutcome {
41
+ version: string;
42
+ result: 'succeeded' | 'failed' | 'canceled';
43
+ source?: 'present' | 'registry' | 'build';
44
+ message: string;
45
+ at: number;
46
+ }
47
+
48
+ /**
49
+ * The last download's outcome, same idea as `finished` in lib/cold-backup.ts: a client
50
+ * that was not mounted for the live `downloading: true → false` transition (navigated
51
+ * away, reloaded, opened the page late) would otherwise see nothing at all once the
52
+ * download is done — this is what it reads instead. Overwritten by the next download,
53
+ * never cleared on its own; the client decides how long "recently" still means something.
54
+ */
55
+ let lastDownloadResult: DownloadOutcome | null = null;
56
+
57
+ export function getLastDownloadResult(): DownloadOutcome | null {
58
+ return lastDownloadResult;
59
+ }
29
60
 
30
61
  /** Checks if a download is already running (in-process flag only). */
31
62
  export function getIsDownloading(): boolean {
@@ -36,6 +67,63 @@ export function getDownloadingVersion(): string | null {
36
67
  return downloadingVersion === 'pending' ? null : downloadingVersion;
37
68
  }
38
69
 
70
+ /** Real progress from `docker pull`'s own per-layer lines; `percent` is null until it can be computed (see `trackPullLayer`). */
71
+ export function getDownloadProgress(): { percent: number | null; message: string | null } {
72
+ return { percent: downloadPercent, message: downloadMessage };
73
+ }
74
+
75
+ /**
76
+ * Cancel the running download, if any: the docker process is killed, or never started
77
+ * when the cancel arrives first (`runDocker` refuses an already-aborted signal). The
78
+ * download rejects with a distinct 'canceled' error so the caller can tell it apart from
79
+ * a real failure. Returns false when nothing is running.
80
+ */
81
+ export function cancelDownload(): boolean {
82
+ if (!downloadController) return false;
83
+ downloadController.abort();
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Whether the last finished download ended as cancelled. Derived from its recorded
89
+ * outcome, not from the request: a cancel that arrives after the image is already tagged
90
+ * changes nothing, and must not make a successful download read as cancelled.
91
+ */
92
+ export function wasDownloadCanceled(): boolean {
93
+ return lastDownloadResult?.result === 'canceled';
94
+ }
95
+
96
+ /**
97
+ * Track `docker pull`'s per-layer lines to compute a real percent: the fraction of
98
+ * layers finished. A layer is finished at `Pull complete` (downloaded *and* extracted) or
99
+ * `Already exists` — not at `Download complete`, which still has the extraction ahead.
100
+ *
101
+ * Not byte-exact (layers differ in size and run in parallel), and deliberately withheld
102
+ * until the first layer starts transferring: docker lists every layer up front, and a
103
+ * percent computed before the list is complete jumps high on a cached first layer and
104
+ * then falls (99 → 50 → 33 → 25 was reproduced). It also never goes down, and stays
105
+ * below 100 — completion is reported by the download's outcome, not by a percent.
106
+ */
107
+ function trackPullLayer(line: string): void {
108
+ const m = /^([0-9a-f]{12}):\s+(.+)$/.exec(line.trim());
109
+ if (!m) return;
110
+ const [, id, state] = m;
111
+ if (/^(Pulling fs layer|Waiting)/.test(state)) {
112
+ if (!pullLayers.has(id)) pullLayers.set(id, 'pending');
113
+ } else if (/^(Pull complete|Already exists)/.test(state)) {
114
+ pullLayers.set(id, 'done');
115
+ } else if (/^(Downloading|Verifying Checksum|Download complete|Extracting)/.test(state)) {
116
+ if (!pullLayers.has(id)) pullLayers.set(id, 'pending');
117
+ pullTransferStarted = true;
118
+ } else {
119
+ return;
120
+ }
121
+ if (!pullTransferStarted || pullLayers.size === 0) return;
122
+ const done = [...pullLayers.values()].filter((v) => v === 'done').length;
123
+ const percent = Math.min(99, Math.round((done / pullLayers.size) * 100));
124
+ downloadPercent = Math.max(downloadPercent ?? 0, percent);
125
+ }
126
+
39
127
  function appendLog(msg: string): void {
40
128
  if (!downloadLogPath) return;
41
129
  try {
@@ -96,53 +184,101 @@ export async function downloadAgentImage(opts: DownloadOptions = {}): Promise<Do
96
184
  if (!dockerAvailable()) throw new Error('Docker is not available — please install Docker and try again');
97
185
  if (downloadingVersion !== null) throw new Error('A download is already in progress');
98
186
  downloadingVersion = opts.version ?? 'pending';
187
+ pullLayers.clear();
188
+ pullTransferStarted = false;
189
+ downloadPercent = null;
190
+ downloadMessage = null;
99
191
  downloadLogPath = `/tmp/rev4a-download-${Date.now()}.log`;
192
+ try {
193
+ // Owner-only, and 'wx' refuses a path that already exists (a planted file or link).
194
+ fs.writeFileSync(downloadLogPath, '', { mode: 0o600, flag: 'wx' });
195
+ } catch {
196
+ downloadLogPath = null;
197
+ }
198
+
199
+ // Owns the child process for this run. A caller's own signal (the setup wizard's SSE
200
+ // stream closing) aborts it too, same as cancelDownload() — both end up here, so
201
+ // `signal.aborted` below is the one place that tells "stopped" from "failed".
202
+ const controller = new AbortController();
203
+ downloadController = controller;
204
+ const onCallerAbort = () => controller.abort();
205
+ if (opts.signal?.aborted) controller.abort();
206
+ else opts.signal?.addEventListener('abort', onCallerAbort, { once: true });
207
+ const { signal } = controller;
208
+ // Between awaits a cancel can arrive with no process to kill yet; stop at the next step.
209
+ const stopIfCanceled = () => { if (signal.aborted) throw new Error('canceled'); };
100
210
 
101
211
  try {
102
212
  const version = opts.version ?? (await defaultDownloadVersion());
213
+ stopIfCanceled();
103
214
  if (!isSupportedVersion(version)) throw new Error(`OpenClaw ${version} is not supported by this Rev4a`);
104
215
  downloadingVersion = version;
105
216
 
106
- if (await localVersionExists(version)) {
217
+ const present = await localVersionExists(version);
218
+ stopIfCanceled();
219
+ if (present) {
107
220
  event('complete', `OpenClaw ${version} image already downloaded`);
221
+ lastDownloadResult = { version, result: 'succeeded', source: 'present', message: `OpenClaw ${version} image already downloaded`, at: Date.now() };
108
222
  return { version, source: 'present' };
109
223
  }
110
224
 
111
225
  event('status', `Downloading OpenClaw ${version} image…`);
112
226
  try {
113
227
  await pullAgentImage(version, {
114
- signal: opts.signal,
228
+ signal,
115
229
  onLine: (line) => {
116
230
  appendLog(line);
231
+ downloadMessage = line;
232
+ trackPullLayer(line);
117
233
  if (/Download|Extract|Pull/.test(line)) opts.onEvent?.(JSON.stringify({ type: 'progress', message: line }));
118
234
  },
119
235
  });
120
236
  event('complete', `OpenClaw ${version} image downloaded`);
237
+ lastDownloadResult = { version, result: 'succeeded', source: 'registry', message: `OpenClaw ${version} image downloaded`, at: Date.now() };
121
238
  return { version, source: 'registry' };
122
239
  } catch (e) {
123
240
  appendLog(`Registry download failed: ${(e as Error).message}`);
124
- if (opts.signal?.aborted || dockerfileVersion() !== version) {
241
+ if (signal.aborted) throw new Error('canceled');
242
+ if (dockerfileVersion() !== version) {
125
243
  throw new Error(`Could not download OpenClaw ${version}: ${(e as Error).message}`);
126
244
  }
127
245
  }
128
246
 
129
247
  event('log', 'Registry download failed, building the image locally…');
248
+ downloadPercent = null;
249
+ stopIfCanceled();
130
250
  await runDocker(
131
251
  ['build', '--build-arg', `OPENCLAW_VERSION=${version}`, '-t', localImageRef(version), '--progress=plain', DOCKERFILE_DIR],
132
252
  {
133
- signal: opts.signal,
253
+ signal,
134
254
  timeoutMs: BUILD_TIMEOUT_MS,
135
255
  onLine: (line) => {
136
256
  appendLog(line);
257
+ downloadMessage = line;
137
258
  if (line.startsWith('#')) opts.onEvent?.(JSON.stringify({ type: 'step', message: line }));
138
259
  },
139
260
  },
140
261
  );
141
262
  event('complete', `OpenClaw ${version} image built locally`);
263
+ lastDownloadResult = { version, result: 'succeeded', source: 'build', message: `OpenClaw ${version} image built locally`, at: Date.now() };
142
264
  return { version, source: 'build' };
265
+ } catch (e) {
266
+ const version = downloadingVersion === 'pending' ? (opts.version ?? 'unknown') : downloadingVersion;
267
+ if (signal.aborted) {
268
+ lastDownloadResult = { version, result: 'canceled', message: `OpenClaw ${version} download canceled`, at: Date.now() };
269
+ throw new Error('canceled');
270
+ }
271
+ lastDownloadResult = { version, result: 'failed', message: (e as Error).message, at: Date.now() };
272
+ throw e;
143
273
  } finally {
274
+ opts.signal?.removeEventListener('abort', onCallerAbort);
144
275
  downloadingVersion = null;
145
276
  downloadLogPath = null;
277
+ downloadController = null;
278
+ pullLayers.clear();
279
+ pullTransferStarted = false;
280
+ downloadPercent = null;
281
+ downloadMessage = null;
146
282
  cleanupOldLogs();
147
283
  }
148
284
  }
@@ -91,16 +91,6 @@ export function initializeRev4aDb(dbPath) {
91
91
  updated_at INTEGER
92
92
  );
93
93
 
94
- CREATE TABLE IF NOT EXISTS system_metrics (
95
- ts INTEGER PRIMARY KEY,
96
- cpu_percent REAL,
97
- ram_used_mb INTEGER,
98
- ram_total_mb INTEGER,
99
- disk_used_gb REAL,
100
- disk_total_gb REAL,
101
- load_avg_1m REAL
102
- );
103
-
104
94
  CREATE TABLE IF NOT EXISTS alert_state (
105
95
  alert_key TEXT PRIMARY KEY,
106
96
  kind TEXT NOT NULL,
@@ -114,7 +104,6 @@ export function initializeRev4aDb(dbPath) {
114
104
  CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
115
105
  CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at);
116
106
  CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
117
- CREATE INDEX IF NOT EXISTS idx_metrics_ts ON system_metrics(ts);
118
107
  CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, ts);
119
108
  CREATE INDEX IF NOT EXISTS idx_alert_state_updated ON alert_state(updated_at);
120
109
  `);
@@ -0,0 +1,48 @@
1
+ // Read access to the machine metrics database (daemon.js writes it).
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import Database from 'better-sqlite3';
5
+ import { DB_PATH } from './db';
6
+
7
+ /** Next to events.db, as daemon.js places it; its own file so the event log stays clean. */
8
+ export const METRICS_DB_PATH = path.join(path.dirname(DB_PATH), 'metrics.db');
9
+
10
+ /**
11
+ * Open the metrics database read-only, or null when there is nothing to read yet: no file
12
+ * (first start, or a daemon that never ran) or a file without the daemon's tables (it
13
+ * failed right after creating it). The daemon owns the schema.
14
+ */
15
+ export function openMetricsDb(): Database.Database | null {
16
+ if (!fs.existsSync(METRICS_DB_PATH)) return null;
17
+ const db = new Database(METRICS_DB_PATH, { readonly: true, fileMustExist: true });
18
+ try {
19
+ const tables = db
20
+ .prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('system_metrics', 'system_disks')")
21
+ .get() as { n: number };
22
+ if (tables.n === 2) return db;
23
+ } catch (e) {
24
+ db.close();
25
+ throw e;
26
+ }
27
+ db.close();
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Seconds-since-epoch of the newest sample, or null when there is none. `available` is
33
+ * false when there is no metrics database to read, `error` when it could not be read.
34
+ * Never throws: a broken metrics.db must not fail a caller about something else.
35
+ */
36
+ export function latestMetricSample(): { available: boolean; ts: number | null; error?: string } {
37
+ let db: Database.Database | null = null;
38
+ try {
39
+ db = openMetricsDb();
40
+ if (!db) return { available: false, ts: null };
41
+ const row = db.prepare('SELECT MAX(ts) AS latest FROM system_metrics').get() as { latest: number | null };
42
+ return { available: true, ts: row.latest };
43
+ } catch (e) {
44
+ return { available: false, ts: null, error: (e as Error).message };
45
+ } finally {
46
+ db?.close();
47
+ }
48
+ }
@@ -27,11 +27,13 @@ export function shortLabel(sessionId: string): string {
27
27
  return sessionId.slice(0, 14);
28
28
  }
29
29
 
30
+ // The two Italian patterns match prompt prefixes stored in older sessions' data; they
31
+ // are matched, never shown, so they stay as they are.
30
32
  function stripTaskBoilerplate(value: string): string {
31
33
  return value
32
34
  .replace(/^\[Subagent Task\]\s*/i, '')
33
- .replace(/^Task richiesto da .*?:\s*/i, '')
34
- .replace(/^Sei [^.\n]+\.\s*/i, '')
35
+ .replace(/^Task richiesto da .*?:\s*/i, '') // lang-ok: matches stored prompt text
36
+ .replace(/^Sei [^.\n]+\.\s*/i, '') // lang-ok: matches stored prompt text
35
37
  .replace(/^Begin\.?\s*/i, '')
36
38
  .trim();
37
39
  }
@@ -4,3 +4,4 @@ export function requireAuthIfConfigured(request: Request): Promise<NextResponse
4
4
  export function getPassword(): string;
5
5
  export function getJwtSecret(): string;
6
6
  export function isConfigured(): boolean;
7
+ export function secretEquals(supplied: unknown, expected: unknown): boolean;
package/lib/rev4a-auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { jwtVerify } from "jose";
3
3
  import * as fs from "fs";
4
+ import { createHash, timingSafeEqual } from "crypto";
4
5
  import { ENV_FILE } from "@/lib/rev4a-paths";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -61,14 +62,17 @@ export async function requireAuthJWT(request) {
61
62
  } catch { /* fall through to bearer */ }
62
63
  }
63
64
 
64
- const expectedToken = process.env.REV4A_TOKEN || process.env.NEXT_PUBLIC_REV4A_TOKEN || "";
65
+ // Only the server-side variable: anything named NEXT_PUBLIC_* is inlined into the
66
+ // browser bundle by Next.js, so accepting one here would hand the API token to every
67
+ // visitor of the dashboard the moment an operator set it.
68
+ const expectedToken = process.env.REV4A_TOKEN || "";
65
69
  // Without this guard an unset token would make `Bearer ` match an empty
66
70
  // expected value. HTTP header normalisation already strips the trailing
67
71
  // space so it isn't reachable in practice — the guard makes that safety
68
72
  // explicit instead of dependent on it.
69
73
  if (expectedToken) {
70
74
  const auth = request.headers.get("authorization");
71
- if (auth === `Bearer ${expectedToken}`) return null;
75
+ if (auth && secretEquals(auth, `Bearer ${expectedToken}`)) return null;
72
76
  }
73
77
 
74
78
  return unauthorized();
@@ -82,3 +86,15 @@ export async function requireAuthIfConfigured(request) {
82
86
  if (!isConfigured()) return null;
83
87
  return requireAuthJWT(request);
84
88
  }
89
+
90
+ /**
91
+ * Compare a supplied secret with the expected one in constant time. Both are hashed to
92
+ * a fixed length first, so neither the content nor the length of the expected value
93
+ * leaks through how long the comparison takes. (The provider gateway's own check in
94
+ * app/api/provider/auth.ts compares the raw bytes after a length guard instead.)
95
+ */
96
+ export function secretEquals(supplied, expected) {
97
+ if (typeof supplied !== "string" || typeof expected !== "string" || !expected) return false;
98
+ const digest = (value) => createHash("sha256").update(value, "utf8").digest();
99
+ return timingSafeEqual(digest(supplied), digest(expected));
100
+ }
package/next.config.mjs CHANGED
@@ -1,7 +1,15 @@
1
+ // Extra hostnames or IPs allowed to reach the dev server (`next dev` only — no effect on
2
+ // a production build), e.g. a LAN address when opening the dashboard from another device.
3
+ // Comma-separated, without scheme or port; none by default.
4
+ const extraDevOrigins = (process.env.REV4A_DEV_ORIGINS ?? '')
5
+ .split(',')
6
+ .map((origin) => origin.trim())
7
+ .filter(Boolean);
8
+
1
9
  const nextConfig = {
2
10
  reactStrictMode: true,
3
11
  serverExternalPackages: ['better-sqlite3'],
4
- allowedDevOrigins: ['187.77.156.41', '127.0.0.1', 'localhost', 'rev4a.srv1490011.hstgr.cloud'],
12
+ allowedDevOrigins: ['127.0.0.1', 'localhost', ...extraDevOrigins],
5
13
  };
6
14
 
7
15
  export default nextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.6.2",
3
+ "version": "1.8.0",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -52,10 +52,10 @@
52
52
  "scripts": {
53
53
  "dev": "next dev -p 3720",
54
54
  "build": "next build",
55
+ "typecheck": "tsc --noEmit",
55
56
  "start": "rev4a serve",
56
57
  "start:all": "rev4a serve",
57
58
  "refresh:pricing": "node scripts/refresh-model-pricing.mjs",
58
- "smoke:db-bootstrap": "node scripts/smoke-db-bootstrap.mjs",
59
59
  "postinstall": "node bin/postinstall.js",
60
60
  "check:language": "node scripts/check-language.mjs",
61
61
  "info:suggest": "node scripts/model-info-suggest.mjs",
package/scripts/backup.sh CHANGED
@@ -1,66 +1,60 @@
1
1
  #!/usr/bin/env bash
2
- # backup.sh — Backup Rev4a data to a tar.gz archive
2
+ # backup.sh — archive Rev4a's own data directory
3
3
  #
4
4
  # Usage:
5
- # sudo bash scripts/backup.sh # backup to ./backups/
6
- # sudo bash scripts/backup.sh /path/to/backups # custom directory
5
+ # bash scripts/backup.sh # archive into ~/rev4a-backups/
6
+ # bash scripts/backup.sh /path/to/backups # custom destination
7
7
  #
8
- # What it backs up:
9
- # - data/*.db (events, credentials, lineage)
10
- # - data/provider-keys.json
11
- # - data/agents-token.json
12
- # - models.config.json
13
- # - .env (credentials and configuration)
8
+ # What it archives: the whole Rev4a data directory — $REV4A_DATA_DIR when set,
9
+ # otherwise ~/.config/rev4a (the same rule as lib/rev4a-paths.ts). That is `.env`
10
+ # (secrets and configuration), everything under `data/` (events.db, metrics.db and
11
+ # credentials.db with their -wal/-shm files, provider-keys.json, agents-token.json, model
12
+ # overrides, the update log) and `shared/` (shared skills and rules). New files there are included
13
+ # automatically.
14
14
  #
15
- # Does NOT back up:
16
- # - node_modules (can be reinstalled)
17
- # - .next build cache (can be rebuilt)
15
+ # Not included: agent volumes (the cold backups in the dashboard cover those), the
16
+ # installed package, node_modules, .next.
17
+ #
18
+ # The archive holds every Rev4a secret in plain text: it is created with mode 0600, and a
19
+ # destination directory the script creates gets mode 0700 (an existing one is left as it
20
+ # is). Keep a copy off the machine, stored securely.
21
+ # For a fully consistent database copy, stop Rev4a first (`systemctl stop rev4a`);
22
+ # a copy taken while it runs is normally fine, since SQLite keeps its WAL alongside.
18
23
  set -euo pipefail
19
24
 
20
- # Resolve script directory
21
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
- REV4A_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
23
- BACKUP_DIR="${1:-${REV4A_ROOT}/backups}"
24
-
25
- mkdir -p "$BACKUP_DIR"
26
-
27
- TS=$(date -u +%Y%m%d-%H%M%S)
28
- ARCHIVE="${BACKUP_DIR}/rev4a-backup-${TS}.tar.gz"
29
-
30
- echo "Backing up Rev4a data..."
31
- echo " Source: ${REV4A_ROOT}"
32
- echo " Target: ${ARCHIVE}"
25
+ DATA_DIR="${REV4A_DATA_DIR:-${HOME}/.config/rev4a}"
26
+ BACKUP_DIR="${1:-${HOME}/rev4a-backups}"
33
27
 
34
- cd "$REV4A_ROOT"
28
+ if [[ ! -d "$DATA_DIR" ]]; then
29
+ echo "Error: Rev4a data directory not found: $DATA_DIR" >&2
30
+ echo "Set REV4A_DATA_DIR if Rev4a keeps its data elsewhere." >&2
31
+ exit 1
32
+ fi
33
+ if [[ ! -f "$DATA_DIR/.env" && ! -d "$DATA_DIR/data" ]]; then
34
+ echo "Error: $DATA_DIR has neither .env nor data/ — refusing to archive an unrelated directory." >&2
35
+ exit 1
36
+ fi
35
37
 
36
- # Restrict permissions during archive creation
37
38
  umask 077
38
-
39
- # Create tar with only the files we care about
40
- tar -czf "$ARCHIVE" \
41
- --exclude='node_modules' \
42
- --exclude='.next' \
43
- --exclude='.git' \
44
- --exclude='backups' \
45
- data/*.db \
46
- data/*.db-shm \
47
- data/*.db-wal \
48
- data/provider-keys.json 2>/dev/null || true \
49
- data/agents-token.json 2>/dev/null || true \
50
- models.config.json 2>/dev/null || true \
51
- .env 2>/dev/null || true
52
-
53
- echo ""
54
- echo "=============================================="
55
- echo " Backup complete!"
56
- echo "=============================================="
57
- echo ""
58
- echo " Archive: ${ARCHIVE}"
59
- echo " Size: $(du -h "$ARCHIVE" | cut -f1)"
60
- echo ""
39
+ mkdir -p "$BACKUP_DIR"
40
+ # The archive must not end up inside what it archives. Both paths are compared once they
41
+ # exist and with symlinks resolved, so a relative or linked destination cannot slip past.
42
+ case "$(cd "$BACKUP_DIR" && pwd -P)/" in
43
+ "$(cd "$DATA_DIR" && pwd -P)/"*) echo "Error: the destination is inside the data directory." >&2; exit 1 ;;
44
+ esac
45
+
46
+ ARCHIVE="${BACKUP_DIR}/rev4a-backup-$(date -u +%Y%m%d-%H%M%S).tar.gz"
47
+ echo "Backing up Rev4a data"
48
+ echo " Source: $DATA_DIR"
49
+ echo " Target: $ARCHIVE"
50
+
51
+ # noclobber: a second backup in the same second fails instead of overwriting the first.
52
+ set -o noclobber
53
+ tar -C "$DATA_DIR" -czf - . > "$ARCHIVE"
54
+ set +o noclobber
61
55
  chmod 600 "$ARCHIVE"
62
- echo " WARNING: This archive contains plain-text credentials (.env)."
63
- echo " Store it securely (encrypted at rest recommended)."
56
+
64
57
  echo ""
65
- echo " To restore: sudo bash scripts/restore.sh ${ARCHIVE}"
66
- echo "=============================================="
58
+ echo "Backup complete: $(tar -tzf "$ARCHIVE" | grep -vc '/$') files, $(du -h "$ARCHIVE" | cut -f1)"
59
+ echo "It contains plain-text secrets (.env, keys, credentials): keep a copy off this machine, stored securely."
60
+ echo "To restore: bash scripts/restore.sh $ARCHIVE"