@flame0510/project-aether 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -2
- package/agent-templates/atlas/HEARTBEAT.md +1 -1
- package/app/agents/ImageDownloadBanner.tsx +171 -37
- package/app/api/agents/download-image/route.ts +29 -5
- package/app/api/agents/image-status/route.ts +17 -2
- package/app/api/assistant/route.ts +13 -4
- package/app/api/auth/login/route.ts +2 -2
- package/app/api/setup/agent-image/route.ts +6 -4
- package/app/api/system-health/route.ts +3 -3
- package/app/components/DashboardToolbar.tsx +2 -2
- package/app/components/LineageGraphPage.tsx +4 -4
- package/app/components/SessionDrawer.tsx +8 -8
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/globals.css +1 -1
- package/app/setup/PageClient.tsx +1 -1
- package/bin/postinstall.js +5 -1
- package/daemon.js +13 -34
- package/docs/ARCHITECTURE.md +44 -26
- package/docs/CONTAINER-TERMINAL.md +17 -8
- package/docs/DESIGN-SYSTEM.md +21 -12
- package/docs/FRONTEND-ARCHITECTURE.md +2 -2
- package/docs/REV4A.md +35 -85
- package/docs/dev/API-REFERENCE.md +73 -19
- package/docs/dev/DATABASE.md +18 -7
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
- package/docs/rag/DATA-FRESHNESS.md +20 -9
- package/docs/rag/GLOSSARY.md +2 -2
- package/docs/rag/REV4A-OVERVIEW.md +10 -6
- package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
- package/lib/agent-images.ts +43 -14
- package/lib/buildAgentImage.ts +142 -6
- package/lib/patterns/sessionPresentation.ts +4 -2
- package/lib/rev4a-auth.d.ts +1 -0
- package/lib/rev4a-auth.js +18 -2
- package/next.config.mjs +9 -1
- package/package.json +2 -2
- package/scripts/backup.sh +48 -54
- package/scripts/check-language.mjs +21 -3
- package/scripts/restore.sh +77 -59
package/lib/agent-images.ts
CHANGED
|
@@ -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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
285
|
-
*
|
|
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
|
-
|
|
295
|
-
|
|
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
|
/**
|
package/lib/buildAgentImage.ts
CHANGED
|
@@ -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).
|
|
15
|
-
* /tmp/rev4a-download-<timestamp>.log
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
}
|
|
@@ -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
|
}
|
package/lib/rev4a-auth.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: ['
|
|
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.
|
|
3
|
+
"version": "1.7.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 —
|
|
2
|
+
# backup.sh — archive Rev4a's own data directory
|
|
3
3
|
#
|
|
4
4
|
# Usage:
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# bash scripts/backup.sh # archive into ~/rev4a-backups/
|
|
6
|
+
# bash scripts/backup.sh /path/to/backups # custom destination
|
|
7
7
|
#
|
|
8
|
-
# What it
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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 and credentials.db
|
|
11
|
+
# with their -wal/-shm files, provider-keys.json, agents-token.json, model overrides,
|
|
12
|
+
# the update log) and `shared/` (shared skills and rules). New files there are included
|
|
13
|
+
# automatically.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
63
|
-
echo " Store it securely (encrypted at rest recommended)."
|
|
56
|
+
|
|
64
57
|
echo ""
|
|
65
|
-
echo "
|
|
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"
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* identifiers, comments, docs, UI strings (AGENTS.md §2.1).
|
|
5
5
|
*
|
|
6
6
|
* The word list is deliberately conservative: only words that are **not** English words.
|
|
7
|
-
* `come`, `per`, `non`, `la`, `al
|
|
7
|
+
* `come`, `per`, `non`, `la`, `al` are left out on purpose, they would fire on
|
|
8
8
|
* English text; the list is content words plus unmistakable function words. A line
|
|
9
9
|
* carrying `lang-ok` is skipped, for the legitimate case where the Italian is the point
|
|
10
10
|
* (a fixture, a note about the rule):
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* // lang-ok: the exact text a user reported
|
|
13
13
|
*
|
|
14
14
|
* It catches Italian *words*, not Italian thinking written in English, and it never
|
|
15
|
-
* translates: the fix is manual.
|
|
16
|
-
*
|
|
15
|
+
* translates: the fix is manual. Run by CI on every push to main and every pull request,
|
|
16
|
+
* again by the release workflow before publishing, and available locally as
|
|
17
|
+
* `npm run check:language` (and as a pre-commit hook, `.githooks/pre-commit`).
|
|
17
18
|
*
|
|
18
19
|
* Usage:
|
|
19
20
|
* node scripts/check-language.mjs # every tracked file
|
|
@@ -35,6 +36,23 @@ const WORDS = [
|
|
|
35
36
|
'essere', 'fare', 'dove', 'nuovo', 'nuova', 'vecchio', 'vecchia', 'davvero', 'invece',
|
|
36
37
|
'inoltre', 'quindi', 'della', 'delle', 'degli', 'nella', 'nelle', 'negli', 'dalla', 'dalle',
|
|
37
38
|
'dallo', 'allo', 'alla', 'sul', 'sulla', 'nessuno', 'nessuna', 'adesso', 'subito', 'oggi', 'ieri',
|
|
39
|
+
// Added 2026-09-24, after three lines survived the list above — one of them a UI string
|
|
40
|
+
// ('Include OpenRouter e altri provider…' in the system-health recommendations).
|
|
41
|
+
'compila', 'compilare', 'compilato', 'pubblica', 'pubblico', 'pubblicare', 'pubblicato',
|
|
42
|
+
'altro', 'altra', 'altri', 'altre', 'giorno', 'giorni', 'mese', 'settimana', 'settimane',
|
|
43
|
+
'aggiorna', 'aggiornare', 'aggiornamento', 'aggiornato', 'controllo', 'controlla', 'controllare',
|
|
44
|
+
'verifica', 'verificare', 'esempio', 'esempi', 'funziona', 'funzione', 'funzioni',
|
|
45
|
+
'creare', 'cancella', 'cancellare', 'scarica', 'scaricare', 'usare', 'usato', 'utilizzo',
|
|
46
|
+
'riga', 'parola', 'parole', 'elenco', 'codice', 'chiave', 'chiavi', 'prova', 'risultato',
|
|
47
|
+
'risultati', 'stesso', 'stessa', 'stessi', 'stesse', 'ancora', 'sempre', 'dopo', 'forse',
|
|
48
|
+
'puoi', 'posso', 'siamo', 'abbiamo', 'erano', 'servono', 'vuole', 'vuoi', 'quale', 'quali',
|
|
49
|
+
'disponibile', 'necessario', 'corretto', 'sbagliato', 'mancante', 'vuoto', 'ultimo', 'ultima',
|
|
50
|
+
'italiano', 'italiana', 'italiane', 'inglese', 'pero', 'cosi', 'piu', 'ecco', 'grazie',
|
|
51
|
+
// Added after a later review found UI labels, alert text and a doc line the list above
|
|
52
|
+
// missed (session drawer, lineage and dashboard toolbars, system-health alerts).
|
|
53
|
+
'stato', 'tipo', 'costo', 'costi', 'durata', 'inizio', 'aggiorn', 'azioni', 'recenti',
|
|
54
|
+
'grafo', 'reimposta', 'periodo', 'agenti', 'agente', 'apri', 'soglia', 'coprire',
|
|
55
|
+
'contenuti', 'allinea', 'allineare', 'campioni', 'consecutivi',
|
|
38
56
|
];
|
|
39
57
|
|
|
40
58
|
const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md', '.json', '.sh', '.yml', '.yaml', '.css', '.html'];
|