@flame0510/project-aether 1.1.12 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +140 -34
  3. package/app/agents/create/PageClient.tsx +11 -4
  4. package/app/api/agents/[id]/recreate/route.ts +3 -3
  5. package/app/api/agents/[id]/route.ts +3 -1
  6. package/app/api/agents/channels-summary/route.ts +48 -17
  7. package/app/api/agents/create/route.ts +32 -13
  8. package/app/api/agents/download-image/route.ts +2 -0
  9. package/app/api/agents/image-status/route.ts +94 -85
  10. package/app/api/agents/route.ts +10 -53
  11. package/app/api/agents/token/route.ts +4 -1
  12. package/app/api/auth/check/route.ts +2 -23
  13. package/app/api/auth/login/route.ts +3 -35
  14. package/app/api/auth/logout/route.ts +1 -1
  15. package/app/api/auth/status/route.ts +2 -21
  16. package/app/api/config/env/route.ts +7 -3
  17. package/app/api/config/restart/route.ts +2 -0
  18. package/app/api/containers/route.ts +3 -34
  19. package/app/api/crons/route.ts +2 -0
  20. package/app/api/debug/route.ts +2 -0
  21. package/app/api/envcheck/route.ts +3 -1
  22. package/app/api/gateway/agent/route.ts +8 -6
  23. package/app/api/gateway/provider/route.ts +43 -5
  24. package/app/api/gateway/route.ts +104 -136
  25. package/app/api/gateway/sync.ts +12 -15
  26. package/app/api/models/route.ts +2 -0
  27. package/app/api/provider/upstream.ts +12 -3
  28. package/app/api/setup/agent-image/route.ts +8 -3
  29. package/app/api/setup/password/route.ts +32 -37
  30. package/app/api/setup/restart/route.ts +8 -2
  31. package/app/api/skills/delete/route.js +1 -1
  32. package/app/api/skills/promote/route.js +1 -1
  33. package/app/api/skills/route.js +89 -97
  34. package/app/api/skills/save/route.js +18 -5
  35. package/app/api/update-check/route.ts +4 -23
  36. package/app/api/wizard/complete/route.ts +7 -3
  37. package/app/api/wizard/reset/route.ts +8 -3
  38. package/app/api/wizard/status/route.ts +8 -5
  39. package/app/components/AuthGuard.tsx +29 -8
  40. package/app/components/Skeleton.tsx +71 -0
  41. package/app/components/ui/ModalityIcons.tsx +157 -0
  42. package/app/components/ui/index.ts +1 -0
  43. package/app/crons/PageClient.tsx +32 -7
  44. package/app/gateway/PageClient.tsx +104 -16
  45. package/bin/rev4a.js +35 -24
  46. package/docs/ARCHITECTURE.md +31 -14
  47. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  48. package/docs/REV4A.md +36 -6
  49. package/docs/dev/API-REFERENCE.md +13 -8
  50. package/docs/dev/GATEWAY.md +42 -1
  51. package/docs/dev/PROVIDERS.md +4 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  53. package/instrumentation.ts +18 -0
  54. package/lib/agent-setup.ts +8 -9
  55. package/lib/buildAgentImage.ts +5 -1
  56. package/lib/channelManager.ts +23 -16
  57. package/lib/db.ts +0 -10
  58. package/lib/docker-exec.ts +195 -0
  59. package/lib/docker-socket.ts +121 -0
  60. package/lib/docker-utils.ts +22 -48
  61. package/lib/openclaw-cron.ts +81 -48
  62. package/lib/openrouter-pricing.ts +184 -0
  63. package/lib/rev4a-auth.d.ts +4 -1
  64. package/lib/rev4a-auth.js +68 -20
  65. package/lib/rev4a-paths.ts +19 -0
  66. package/model-pricing.json +50 -50
  67. package/models.config.json +2 -2
  68. package/package.json +4 -1
  69. package/proxy.ts +68 -0
  70. package/scripts/refresh-model-pricing.mjs +171 -0
  71. package/lib/auth.ts +0 -28
  72. package/lib/requireAuth.tsx +0 -38
@@ -2,125 +2,134 @@ import { type NextRequest } from "next/server";
2
2
  /**
3
3
  * GET /api/agents/image-status
4
4
  *
5
- * Checks whether the openclaw-agent-base image is present and up to date.
6
- * Compares the local image digest with the remote registry digest
7
- * (public image on ghcr.io, no auth needed).
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 { execSync } from 'child_process';
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
- function dockerOk(): boolean {
20
- try {
21
- execSync('docker version --format "{{.Client.Version}}" 2>/dev/null', {
22
- encoding: 'utf-8',
23
- timeout: 2000,
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
- function localImageExists(): boolean {
32
- if (!dockerOk()) return false;
33
- try {
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
- function getLocalImageId(): string | null {
45
- if (!dockerOk()) return null;
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
- return execSync(
48
- `docker image inspect ${LOCAL_IMAGE} --format "{{.ID}}" 2>/dev/null`,
49
- { encoding: 'utf-8', timeout: 3000 },
50
- ).trim() || null;
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
- /** Fetch the remote manifest digest from ghcr.io (public, no auth). */
57
- function getRemoteDigest(): string | null {
58
- if (!dockerOk()) return null;
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 out = execSync(
61
- `docker manifest inspect ${REGISTRY_IMAGE} --verbose 2>/dev/null`,
62
- { encoding: 'utf-8', timeout: 15000 },
63
- );
64
- const manifest = JSON.parse(out);
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 exists = localImageExists();
75
- const localId = getLocalImageId();
104
+ const denied = await requireAuthJWT(request);
105
+ if (denied) return denied;
106
+
76
107
  const downloading = getIsDownloading();
77
108
 
78
- if (!exists) {
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
- // Compare local image digest with remote registry digest.
88
- // If they differ, a newer image is available.
89
- let needsUpdate = false;
90
- const remoteDigest = getRemoteDigest();
113
+ const local = await inspectLocalImage();
114
+ if (!local) {
115
+ return NextResponse.json({ exists: false, needsUpdate: true, downloading, localId: null });
116
+ }
91
117
 
92
- if (remoteDigest && localId) {
93
- // localId is the image ID (e.g. sha256:abc123...).
94
- // Extract it from any RepoDigest that references the registry.
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
- if (registryDigest) {
108
- // Extract sha256:... from "ghcr.io/...@sha256:xxx"
109
- const localDigest = registryDigest.split('@')[1] || '';
110
- needsUpdate = localDigest !== remoteDigest;
111
- } else {
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
  }
@@ -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 traefikUrl: string | null = null;
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
- // Build the agent URL from the host's public IP + mapped port.
128
- // Every agent has -p <port>:3000, so we look for the public port
129
- // that maps to the internal port 3000.
130
- // Use portBindings from inspect (works stopped) with fallback to c.Ports (running only).
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
- const publicPort = port3000Binding?.[0]?.HostPort
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
- traefikUrl,
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); if (denied) return denied as any;
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 * as fs from 'fs';
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 * as fs from 'fs';
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 = getEnvPassword();
40
- const jwtSecret = getEnvJwtSecret();
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 * as fs from 'fs';
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
- let password = process.env.REV4A_PASSWORD;
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 (authenticated session via middleware).
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); if (denied) return denied as any;
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-next.service already reads .env via EnvironmentFile=.
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
  }
@@ -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
  }
@@ -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
- // Restart gateway to pick up the changes
130
- try {
131
- execSync(`docker exec ${safeContainer} openclaw gateway restart`, { timeout: 15000, maxBuffer: 64 * 1024 });
132
- } catch {
133
- // restart failure is non-fatal, config was written
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');