@flame0510/project-aether 1.1.12 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/app/agents/PageClient.tsx +140 -34
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +32 -13
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +94 -85
- package/app/api/agents/route.ts +10 -53
- package/app/api/agents/token/route.ts +4 -1
- package/app/api/auth/check/route.ts +2 -23
- package/app/api/auth/login/route.ts +3 -35
- package/app/api/auth/logout/route.ts +1 -1
- package/app/api/auth/status/route.ts +2 -21
- package/app/api/config/env/route.ts +7 -3
- package/app/api/config/restart/route.ts +2 -0
- package/app/api/containers/route.ts +3 -34
- package/app/api/crons/route.ts +2 -0
- package/app/api/debug/route.ts +2 -0
- package/app/api/envcheck/route.ts +3 -1
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +104 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/models/route.ts +2 -0
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/setup/agent-image/route.ts +8 -3
- package/app/api/setup/password/route.ts +32 -37
- package/app/api/setup/restart/route.ts +8 -2
- package/app/api/skills/delete/route.js +1 -1
- package/app/api/skills/promote/route.js +1 -1
- package/app/api/skills/route.js +89 -97
- package/app/api/skills/save/route.js +18 -5
- package/app/api/update-check/route.ts +4 -23
- package/app/api/wizard/complete/route.ts +7 -3
- package/app/api/wizard/reset/route.ts +8 -3
- package/app/api/wizard/status/route.ts +8 -5
- package/app/components/AuthGuard.tsx +29 -8
- package/app/components/Skeleton.tsx +71 -0
- package/app/components/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/crons/PageClient.tsx +32 -7
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -14
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +13 -8
- package/docs/dev/GATEWAY.md +42 -1
- package/docs/dev/PROVIDERS.md +4 -3
- package/docs/rag/REV4A-OVERVIEW.md +1 -1
- package/instrumentation.ts +18 -0
- package/lib/agent-setup.ts +8 -9
- package/lib/buildAgentImage.ts +5 -1
- package/lib/channelManager.ts +23 -16
- package/lib/db.ts +0 -10
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-socket.ts +121 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/lib/openrouter-pricing.ts +184 -0
- package/lib/rev4a-auth.d.ts +4 -1
- package/lib/rev4a-auth.js +68 -20
- package/lib/rev4a-paths.ts +19 -0
- package/model-pricing.json +50 -50
- package/models.config.json +2 -2
- package/package.json +4 -1
- package/proxy.ts +68 -0
- package/scripts/refresh-model-pricing.mjs +171 -0
- package/lib/auth.ts +0 -28
- package/lib/requireAuth.tsx +0 -38
|
@@ -2,125 +2,134 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
/**
|
|
3
3
|
* GET /api/agents/image-status
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Reports whether the openclaw-agent-base image is present, whether a download
|
|
6
|
+
* is in flight, and whether a newer image exists in the registry.
|
|
7
|
+
*
|
|
8
|
+
* The four fields cost wildly different amounts, which is what makes this route
|
|
9
|
+
* worth structuring carefully. ImageDownloadBanner polls it every 2s to animate
|
|
10
|
+
* download progress, so anything slow in here is paid over and over:
|
|
11
|
+
*
|
|
12
|
+
* downloading in-memory flag free
|
|
13
|
+
* error local free
|
|
14
|
+
* exists one `docker image inspect` ~0.5s
|
|
15
|
+
* needsUpdate `docker manifest inspect` over the net ~3.4s
|
|
16
|
+
*
|
|
17
|
+
* The route used to compute all of them, synchronously, on every poll — a 2s
|
|
18
|
+
* interval driving a ~5s blocking call, so the whole server sat frozen for as
|
|
19
|
+
* long as the agents page stayed open. It also ran `docker version` three times
|
|
20
|
+
* per request (once inside each helper) just to ask whether Docker was up, at
|
|
21
|
+
* ~2s a call, and inspected the same image three times over.
|
|
22
|
+
*
|
|
23
|
+
* Now: Docker liveness comes from the socket check in lib/docker-socket (a
|
|
24
|
+
* cached filesystem stat), the image is inspected once, everything is async, and
|
|
25
|
+
* the registry lookup — the only genuinely expensive part — is cached. The base
|
|
26
|
+
* image changes when a release is published, not every two seconds.
|
|
8
27
|
*/
|
|
9
28
|
import { NextResponse } from 'next/server';
|
|
10
|
-
import {
|
|
29
|
+
import { execFile } from 'child_process';
|
|
30
|
+
import { promisify } from 'util';
|
|
11
31
|
import { getIsDownloading } from '@/lib/buildAgentImage';
|
|
12
32
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
33
|
+
import { dockerAvailable } from '@/lib/docker-socket';
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
13
36
|
|
|
14
37
|
export const dynamic = 'force-dynamic';
|
|
15
38
|
|
|
16
39
|
const REGISTRY_IMAGE = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
|
|
17
40
|
const LOCAL_IMAGE = 'openclaw-agent-base:latest';
|
|
41
|
+
const REGISTRY_PREFIX = 'ghcr.io/flame0510/rev4a/openclaw-agent-base@';
|
|
18
42
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return true;
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
43
|
+
/**
|
|
44
|
+
* How long a registry lookup is reused. Long enough that a 2s poll doesn't hit
|
|
45
|
+
* the network, short enough that a freshly published base image surfaces on its
|
|
46
|
+
* own. The cost of being stale here is a banner appearing a few minutes late.
|
|
47
|
+
*/
|
|
48
|
+
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
execSync(
|
|
35
|
-
`docker image inspect ${LOCAL_IMAGE} --format "{{.ID}}" 2>/dev/null`,
|
|
36
|
-
{ timeout: 3000 },
|
|
37
|
-
);
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
50
|
+
let remoteCache: { digest: string | null; at: number } | null = null;
|
|
51
|
+
/** Shared across concurrent requests so a burst of polls triggers one lookup. */
|
|
52
|
+
let remoteInFlight: Promise<string | null> | null = null;
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
/** The local image's ID and its registry digest, from a single inspect. */
|
|
55
|
+
async function inspectLocalImage(): Promise<{ id: string; registryDigest: string | null } | null> {
|
|
46
56
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
const { stdout } = await execFileAsync(
|
|
58
|
+
'docker',
|
|
59
|
+
['image', 'inspect', LOCAL_IMAGE, '--format', '{{json .}}'],
|
|
60
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
61
|
+
);
|
|
62
|
+
const info = JSON.parse(stdout) as { Id?: string; RepoDigests?: string[] };
|
|
63
|
+
const fromRegistry = (info.RepoDigests ?? []).find((d) => d.startsWith(REGISTRY_PREFIX));
|
|
64
|
+
return {
|
|
65
|
+
id: info.Id ?? '',
|
|
66
|
+
registryDigest: fromRegistry ? fromRegistry.split('@')[1] ?? null : null,
|
|
67
|
+
};
|
|
51
68
|
} catch {
|
|
52
69
|
return null;
|
|
53
70
|
}
|
|
54
71
|
}
|
|
55
72
|
|
|
56
|
-
/**
|
|
57
|
-
function getRemoteDigest(): string | null {
|
|
58
|
-
if (
|
|
73
|
+
/** Manifest digest of the published image, cached for REMOTE_TTL_MS. */
|
|
74
|
+
async function getRemoteDigest(): Promise<string | null> {
|
|
75
|
+
if (remoteCache && Date.now() - remoteCache.at < REMOTE_TTL_MS) return remoteCache.digest;
|
|
76
|
+
if (remoteInFlight) return remoteInFlight;
|
|
77
|
+
|
|
78
|
+
remoteInFlight = (async () => {
|
|
79
|
+
try {
|
|
80
|
+
const { stdout } = await execFileAsync(
|
|
81
|
+
'docker',
|
|
82
|
+
['manifest', 'inspect', REGISTRY_IMAGE, '--verbose'],
|
|
83
|
+
{ encoding: 'utf-8', timeout: 15000 },
|
|
84
|
+
);
|
|
85
|
+
const manifest = JSON.parse(stdout) as { Descriptor?: { digest?: string } };
|
|
86
|
+
// The descriptor digest is what RepoDigests can be compared against;
|
|
87
|
+
// config.digest is a different hash entirely.
|
|
88
|
+
return manifest?.Descriptor?.digest ?? null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
|
|
59
94
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Use the manifest descriptor digest (comparable with RepoDigests),
|
|
66
|
-
// not config.digest (which is a different hash).
|
|
67
|
-
return manifest?.Descriptor?.digest || null;
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
95
|
+
const digest = await remoteInFlight;
|
|
96
|
+
remoteCache = { digest, at: Date.now() };
|
|
97
|
+
return digest;
|
|
98
|
+
} finally {
|
|
99
|
+
remoteInFlight = null;
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
74
|
-
const
|
|
75
|
-
|
|
104
|
+
const denied = await requireAuthJWT(request);
|
|
105
|
+
if (denied) return denied;
|
|
106
|
+
|
|
76
107
|
const downloading = getIsDownloading();
|
|
77
108
|
|
|
78
|
-
if (!
|
|
79
|
-
return NextResponse.json({
|
|
80
|
-
exists: false,
|
|
81
|
-
needsUpdate: true,
|
|
82
|
-
downloading,
|
|
83
|
-
localId: null,
|
|
84
|
-
});
|
|
109
|
+
if (!dockerAvailable()) {
|
|
110
|
+
return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
const local = await inspectLocalImage();
|
|
114
|
+
if (!local) {
|
|
115
|
+
return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
|
|
116
|
+
}
|
|
91
117
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const inspect = JSON.parse(
|
|
97
|
-
execSync(
|
|
98
|
-
`docker image inspect ${LOCAL_IMAGE} --format '{{json .}}' 2>/dev/null`,
|
|
99
|
-
{ encoding: 'utf-8', timeout: 3000 },
|
|
100
|
-
),
|
|
101
|
-
);
|
|
102
|
-
const localRepoDigests: string[] = inspect?.RepoDigests || [];
|
|
103
|
-
const registryDigest = localRepoDigests.find((d: string) =>
|
|
104
|
-
d.startsWith('ghcr.io/flame0510/rev4a/openclaw-agent-base@'),
|
|
105
|
-
);
|
|
118
|
+
// A cached registry digest answers immediately; a cold one costs a network
|
|
119
|
+
// round trip, but only once per TTL rather than once per poll.
|
|
120
|
+
const remoteDigest = await getRemoteDigest();
|
|
106
121
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// Image was built locally, never pulled — needs update
|
|
113
|
-
needsUpdate = true;
|
|
114
|
-
}
|
|
115
|
-
} catch {
|
|
116
|
-
// Can't determine — assume up to date
|
|
117
|
-
}
|
|
122
|
+
let needsUpdate = false;
|
|
123
|
+
if (remoteDigest) {
|
|
124
|
+
// No registry digest locally means the image was built here and never
|
|
125
|
+
// pulled, so it can't be compared — treat it as updatable.
|
|
126
|
+
needsUpdate = local.registryDigest ? local.registryDigest !== remoteDigest : true;
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
return NextResponse.json({
|
|
121
130
|
exists: true,
|
|
122
131
|
needsUpdate,
|
|
123
132
|
downloading,
|
|
124
|
-
localId,
|
|
133
|
+
localId: local.id || null,
|
|
125
134
|
});
|
|
126
135
|
}
|
package/app/api/agents/route.ts
CHANGED
|
@@ -1,48 +1,12 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import * as http from 'http';
|
|
3
2
|
import * as fs from 'fs';
|
|
4
3
|
import * as path from 'path';
|
|
5
|
-
import { detectPublicHost } from '@/lib/docker-utils';
|
|
6
4
|
import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
|
|
7
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
8
|
-
|
|
9
|
-
const DOCKER_SOCKET = '/var/run/docker.sock';
|
|
10
|
-
|
|
11
|
-
function dockerFetch(method: string, path: string): Promise<any> {
|
|
12
|
-
return new Promise((resolve, reject) => {
|
|
13
|
-
const options = {
|
|
14
|
-
socketPath: DOCKER_SOCKET,
|
|
15
|
-
path,
|
|
16
|
-
method,
|
|
17
|
-
headers: { 'Host': 'localhost' } as any,
|
|
18
|
-
};
|
|
19
|
-
const req = http.request(options, (res: any) => {
|
|
20
|
-
let data = '';
|
|
21
|
-
res.on('data', (chunk: string) => (data += chunk));
|
|
22
|
-
res.on('end', () => {
|
|
23
|
-
try {
|
|
24
|
-
resolve(JSON.parse(data));
|
|
25
|
-
} catch {
|
|
26
|
-
reject(new Error('Invalid JSON from Docker API'));
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
req.on('error', reject);
|
|
31
|
-
req.end();
|
|
32
|
-
});
|
|
33
|
-
}
|
|
6
|
+
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
34
7
|
|
|
35
8
|
export const dynamic = 'force-dynamic';
|
|
36
9
|
|
|
37
|
-
function dockerAvailable(): boolean {
|
|
38
|
-
try {
|
|
39
|
-
fs.accessSync(DOCKER_SOCKET, fs.constants.R_OK | fs.constants.W_OK);
|
|
40
|
-
return true;
|
|
41
|
-
} catch {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
10
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
47
11
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
48
12
|
// If Docker is not available (e.g. testing environment), return empty list
|
|
@@ -73,7 +37,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
73
37
|
let created: string | null = null;
|
|
74
38
|
let env: string[] = [];
|
|
75
39
|
let authToken: string | null = null;
|
|
76
|
-
let
|
|
40
|
+
let controlPort: string | null = null;
|
|
77
41
|
let portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> | null = null;
|
|
78
42
|
|
|
79
43
|
try {
|
|
@@ -124,22 +88,15 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
124
88
|
}
|
|
125
89
|
}
|
|
126
90
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
91
|
+
// The host port mapped to the agent's Control UI (container port 3000).
|
|
92
|
+
// Only the port is returned: the browser composes the full URL, because
|
|
93
|
+
// only the browser knows which hostname actually reaches this machine.
|
|
94
|
+
// Read from portBindings (works on a stopped container) with a fallback
|
|
95
|
+
// to c.Ports, which is only populated while running.
|
|
131
96
|
const port3000Binding = portBindings?.['3000/tcp'];
|
|
132
|
-
|
|
133
|
-
|| (c.Ports || []).find((p: any) => p.PublicPort && p.PrivatePort === 3000)?.PublicPort
|
|
97
|
+
controlPort = port3000Binding?.[0]?.HostPort
|
|
98
|
+
|| (c.Ports || []).find((p: any) => p.PublicPort && p.PrivatePort === 3000)?.PublicPort?.toString()
|
|
134
99
|
|| null;
|
|
135
|
-
if (publicPort) {
|
|
136
|
-
const host = detectPublicHost();
|
|
137
|
-
let baseUrl = `http://${host}:${publicPort}`;
|
|
138
|
-
if (authToken) {
|
|
139
|
-
baseUrl += `#token=${encodeURIComponent(authToken)}`;
|
|
140
|
-
}
|
|
141
|
-
traefikUrl = baseUrl;
|
|
142
|
-
}
|
|
143
100
|
} catch {
|
|
144
101
|
// inspect non-critical, continue with partial data
|
|
145
102
|
}
|
|
@@ -185,7 +142,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
185
142
|
ip,
|
|
186
143
|
created,
|
|
187
144
|
env,
|
|
188
|
-
|
|
145
|
+
controlPort,
|
|
189
146
|
authToken,
|
|
190
147
|
// Flag as outdated when the container's image differs from the current base image.
|
|
191
148
|
// We compare raw ImageID digests — no fragile name-based filtering.
|
|
@@ -29,11 +29,14 @@ function shellQuote(value: string): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
32
|
+
const denied = await requireAuthJWT(request);
|
|
33
|
+
if (denied) return denied;
|
|
32
34
|
return NextResponse.json(readTokenFile());
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export async function PUT(request: NextRequest): Promise<NextResponse> {
|
|
36
|
-
const denied = await requireAuthJWT(request);
|
|
38
|
+
const denied = await requireAuthJWT(request);
|
|
39
|
+
if (denied) return denied;
|
|
37
40
|
try {
|
|
38
41
|
const body = await request.json();
|
|
39
42
|
const token = typeof body?.token === 'string' ? body.token : null;
|
|
@@ -1,33 +1,12 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import { jwtVerify } from 'jose';
|
|
3
|
-
import
|
|
4
|
-
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
5
|
-
|
|
6
|
-
function getJwtSecret(): Uint8Array {
|
|
7
|
-
const fromEnv = process.env.REV4A_JWT_SECRET;
|
|
8
|
-
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
|
|
9
|
-
return new TextEncoder().encode(fromEnv);
|
|
10
|
-
}
|
|
11
|
-
// Fallback: read from persistent .env
|
|
12
|
-
for (const file of [ENV_FILE]) {
|
|
13
|
-
try {
|
|
14
|
-
if (fs.existsSync(file)) {
|
|
15
|
-
const content = fs.readFileSync(file, 'utf-8');
|
|
16
|
-
const match = content.match(/^REV4A_JWT_SECRET=(.*)$/m);
|
|
17
|
-
if (match && match[1].trim()) {
|
|
18
|
-
return new TextEncoder().encode(match[1].trim());
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
} catch { /* ignore */ }
|
|
22
|
-
}
|
|
23
|
-
return new TextEncoder().encode('');
|
|
24
|
-
}
|
|
3
|
+
import { getJwtSecret } from '@/lib/rev4a-auth';
|
|
25
4
|
|
|
26
5
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
27
6
|
const cookieToken = request.cookies.get('rev4a_token')?.value;
|
|
28
7
|
if (cookieToken) {
|
|
29
8
|
try {
|
|
30
|
-
const secret = getJwtSecret();
|
|
9
|
+
const secret = new TextEncoder().encode(getJwtSecret());
|
|
31
10
|
await jwtVerify(cookieToken, secret);
|
|
32
11
|
return NextResponse.json({ authenticated: true });
|
|
33
12
|
} catch {
|
|
@@ -1,43 +1,11 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import { SignJWT } from 'jose';
|
|
3
|
-
import
|
|
4
|
-
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Resolve credentials at runtime from the persistent .env file.
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
function getEnvPassword(): string {
|
|
10
|
-
const fromEnv = process.env.REV4A_PASSWORD;
|
|
11
|
-
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
if (fs.existsSync(ENV_FILE)) {
|
|
15
|
-
const content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
16
|
-
const match = content.match(/^REV4A_PASSWORD=(.*)$/m);
|
|
17
|
-
if (match && match[1].trim()) return match[1].trim();
|
|
18
|
-
}
|
|
19
|
-
} catch { /* ignore */ }
|
|
20
|
-
return '';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function getEnvJwtSecret(): string {
|
|
24
|
-
const fromEnv = process.env.REV4A_JWT_SECRET;
|
|
25
|
-
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
if (fs.existsSync(ENV_FILE)) {
|
|
29
|
-
const content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
30
|
-
const match = content.match(/^REV4A_JWT_SECRET=(.*)$/m);
|
|
31
|
-
if (match && match[1].trim()) return match[1].trim();
|
|
32
|
-
}
|
|
33
|
-
} catch { /* ignore */ }
|
|
34
|
-
return '';
|
|
35
|
-
}
|
|
3
|
+
import { getPassword, getJwtSecret } from '@/lib/rev4a-auth';
|
|
36
4
|
|
|
37
5
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
38
6
|
try {
|
|
39
|
-
const password =
|
|
40
|
-
const jwtSecret =
|
|
7
|
+
const password = getPassword();
|
|
8
|
+
const jwtSecret = getJwtSecret();
|
|
41
9
|
|
|
42
10
|
if (!password) {
|
|
43
11
|
console.error('[auth] No password configured — rejecting login');
|
|
@@ -2,6 +2,6 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
|
|
3
3
|
export async function POST(): Promise<NextResponse> {
|
|
4
4
|
const response = NextResponse.json({ ok: true });
|
|
5
|
-
response.cookies.delete('rev4a_token');
|
|
5
|
+
response.cookies.delete({ name: 'rev4a_token', path: '/' });
|
|
6
6
|
return response;
|
|
7
7
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import
|
|
3
|
-
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
2
|
+
import { isConfigured } from '@/lib/rev4a-auth';
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* GET /api/auth/status
|
|
@@ -11,23 +10,5 @@ import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
|
11
10
|
* the setup wizard or the login form.
|
|
12
11
|
*/
|
|
13
12
|
export function GET(): NextResponse {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// Fallback: read .env from config dir if env var is empty (Next.js inlines at build)
|
|
17
|
-
if (typeof password !== 'string' || password.length === 0) {
|
|
18
|
-
try {
|
|
19
|
-
if (fs.existsSync(ENV_FILE)) {
|
|
20
|
-
const content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
21
|
-
const match = content.match(/^REV4A_PASSWORD=(.*)$/m);
|
|
22
|
-
if (match && match[1].trim()) {
|
|
23
|
-
password = match[1].trim();
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
} catch {
|
|
27
|
-
// ignore
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const configured = typeof password === 'string' && password.length > 0;
|
|
32
|
-
return NextResponse.json({ configured });
|
|
13
|
+
return NextResponse.json({ configured: isConfigured() });
|
|
33
14
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* PUT /api/config/env — upsert one or more env vars
|
|
6
6
|
*
|
|
7
7
|
* Only keys prefixed with REV4A_ or PROVIDER_ are exposed.
|
|
8
|
-
* Auth: browser cookie
|
|
8
|
+
* Auth: requireAuthJWT (browser cookie or bearer token) — API routes are
|
|
9
|
+
* never covered by proxy.ts, it explicitly exempts /api/*.
|
|
9
10
|
*/
|
|
10
11
|
import { NextRequest, NextResponse } from 'next/server';
|
|
11
12
|
import * as fs from 'fs';
|
|
@@ -54,6 +55,8 @@ function serializeEnvFile(vars: Record<string, string>): string {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export async function GET(request: NextRequest) {
|
|
58
|
+
const denied = await requireAuthJWT(request);
|
|
59
|
+
if (denied) return denied;
|
|
57
60
|
try {
|
|
58
61
|
let content = '';
|
|
59
62
|
try {
|
|
@@ -78,7 +81,8 @@ export async function GET(request: NextRequest) {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
export async function PUT(request: NextRequest) {
|
|
81
|
-
const denied = await requireAuthJWT(request);
|
|
84
|
+
const denied = await requireAuthJWT(request);
|
|
85
|
+
if (denied) return denied;
|
|
82
86
|
try {
|
|
83
87
|
const body: { env?: Record<string, string> } = await request.json();
|
|
84
88
|
if (!body.env || typeof body.env !== 'object') {
|
|
@@ -115,7 +119,7 @@ export async function PUT(request: NextRequest) {
|
|
|
115
119
|
|
|
116
120
|
// Reload systemd so EnvironmentFile= picks up .env changes on next restart.
|
|
117
121
|
// No need to write individual Environment= lines into unit files —
|
|
118
|
-
// rev4a
|
|
122
|
+
// rev4a.service already reads .env via EnvironmentFile=.
|
|
119
123
|
try {
|
|
120
124
|
execSync('sudo systemctl daemon-reload', { timeout: 5000 });
|
|
121
125
|
} catch (syncErr) {
|
|
@@ -13,6 +13,8 @@ export const dynamic = 'force-dynamic';
|
|
|
13
13
|
export const runtime = 'nodejs';
|
|
14
14
|
|
|
15
15
|
export async function POST(request: NextRequest) {
|
|
16
|
+
const denied = await requireAuthJWT(request);
|
|
17
|
+
if (denied) return denied;
|
|
16
18
|
try {
|
|
17
19
|
execSync('sudo systemctl daemon-reload && sudo systemctl restart rev4a.service', {
|
|
18
20
|
timeout: 15000,
|
|
@@ -1,44 +1,13 @@
|
|
|
1
1
|
import { type NextRequest } from "next/server";
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
|
-
import * as http from 'http';
|
|
4
|
-
import * as fs from 'fs';
|
|
5
3
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
-
|
|
7
|
-
const DOCKER_SOCKET = '/var/run/docker.sock';
|
|
8
|
-
|
|
9
|
-
function dockerAvailable(): boolean {
|
|
10
|
-
try {
|
|
11
|
-
fs.accessSync(DOCKER_SOCKET, fs.constants.R_OK | fs.constants.W_OK);
|
|
12
|
-
return true;
|
|
13
|
-
} catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function dockerFetch(method: string, path: string): Promise<any> {
|
|
19
|
-
return new Promise((resolve, reject) => {
|
|
20
|
-
const options = {
|
|
21
|
-
socketPath: DOCKER_SOCKET,
|
|
22
|
-
path,
|
|
23
|
-
method,
|
|
24
|
-
headers: { 'Host': 'localhost' } as any,
|
|
25
|
-
};
|
|
26
|
-
const req = http.request(options, (res: any) => {
|
|
27
|
-
let data = '';
|
|
28
|
-
res.on('data', (chunk: string) => data += chunk);
|
|
29
|
-
res.on('end', () => {
|
|
30
|
-
try { resolve(JSON.parse(data)); }
|
|
31
|
-
catch { reject(new Error('Invalid JSON from Docker')); }
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
req.on('error', reject);
|
|
35
|
-
req.end();
|
|
36
|
-
});
|
|
37
|
-
}
|
|
4
|
+
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
38
5
|
|
|
39
6
|
export const dynamic = 'force-dynamic';
|
|
40
7
|
|
|
41
8
|
export async function GET(request: NextRequest) {
|
|
9
|
+
const denied = await requireAuthJWT(request);
|
|
10
|
+
if (denied) return denied;
|
|
42
11
|
if (!dockerAvailable()) {
|
|
43
12
|
return NextResponse.json([]);
|
|
44
13
|
}
|
package/app/api/crons/route.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
|
7
7
|
export const dynamic = 'force-dynamic';
|
|
8
8
|
|
|
9
9
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
10
|
+
const denied = await requireAuthJWT(request);
|
|
11
|
+
if (denied) return denied;
|
|
10
12
|
const result = await listOpenClawCronJobs();
|
|
11
13
|
return NextResponse.json(result.jobs);
|
|
12
14
|
}
|
package/app/api/debug/route.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
|
|
|
3
3
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
4
4
|
|
|
5
5
|
export async function GET(request: NextRequest) {
|
|
6
|
+
const denied = await requireAuthJWT(request);
|
|
7
|
+
if (denied) return denied;
|
|
6
8
|
return NextResponse.json({
|
|
7
9
|
hasPassword: !!process.env.REV4A_PASSWORD,
|
|
8
10
|
hasJwtSecret: !!process.env.REV4A_JWT_SECRET,
|
|
@@ -2,9 +2,11 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
3
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
4
4
|
|
|
5
|
-
const SENSITIVE = ['REV4A_PASSWORD', 'REV4A_TOKEN', 'REV4A_JWT_SECRET'];
|
|
5
|
+
const SENSITIVE = ['REV4A_PASSWORD', 'REV4A_TOKEN', 'REV4A_JWT_SECRET', 'PROVIDER_DEEPSEEK_API_KEY'];
|
|
6
6
|
|
|
7
7
|
export async function GET(request: NextRequest) {
|
|
8
|
+
const denied = await requireAuthJWT(request);
|
|
9
|
+
if (denied) return denied;
|
|
8
10
|
const safe: Record<string, string> = {};
|
|
9
11
|
for (const key of ['REV4A_PASSWORD', 'REV4A_TOKEN', 'REV4A_JWT_SECRET', 'REV4A_ALERTS_ENABLED', 'REV4A_DB', 'REV4A_WORKSPACE_ROOT', 'PROVIDER_DEEPSEEK_API_KEY', 'NODE_ENV']) {
|
|
10
12
|
const val = process.env[key];
|
|
@@ -126,12 +126,14 @@ export async function PUT(request: NextRequest) {
|
|
|
126
126
|
{ timeout: 15000, maxBuffer: 1024 * 1024, input: newConfig },
|
|
127
127
|
);
|
|
128
128
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
// No gateway restart: the Gateway watches openclaw.json and hot-applies this.
|
|
130
|
+
// Confirmed against a live agent by writing the file exactly the way this
|
|
131
|
+
// route does — the container logged:
|
|
132
|
+
// [reload] config change detected; evaluating reload (agents.defaults.model.primary)
|
|
133
|
+
// [reload] config hot reload applied (agents.defaults.model.primary)
|
|
134
|
+
// OpenClaw's docs put `agents` in the no-restart column, and gateway.reload
|
|
135
|
+
// defaults to `hybrid`, which restarts on its own for the sections that do
|
|
136
|
+
// need it. The restart here cost ~7.5s of fully blocked event loop.
|
|
135
137
|
|
|
136
138
|
// Verify
|
|
137
139
|
const verifyRaw = readFileFromContainer(safeContainer, '/root/.openclaw/openclaw.json');
|