@flame0510/project-aether 1.1.12 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/app/agents/PageClient.tsx +84 -22
- package/app/api/agents/create/route.ts +15 -3
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +2 -0
- package/app/api/agents/route.ts +1 -36
- 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/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +2 -0
- package/app/api/models/route.ts +2 -0
- 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 +1 -1
- 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/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -13
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +9 -4
- 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/db.ts +0 -10
- package/lib/docker-socket.ts +121 -0
- 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
package/README.md
CHANGED
|
@@ -44,7 +44,10 @@ rev4a serve --port 8080
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
> **Production install:** see `CONNECT.md` for systemd deployment on a VPS.
|
|
47
|
-
>
|
|
47
|
+
>
|
|
48
|
+
> **Container deploy:** on hold. The root `Dockerfile` (Docker-in-Docker packaging of
|
|
49
|
+
> the dashboard itself) is an experimental path, not currently built or published —
|
|
50
|
+
> see the file for details. Use the git-clone install above.
|
|
48
51
|
|
|
49
52
|
---
|
|
50
53
|
|
|
@@ -216,12 +216,15 @@ export default function AgentsPageClient() {
|
|
|
216
216
|
const [loading, setLoading] = useState(true);
|
|
217
217
|
const [error, setError] = useState('');
|
|
218
218
|
const [filter, setFilter] = useState<FilterState>('all');
|
|
219
|
-
const [
|
|
219
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
220
220
|
const [isMobile, setIsMobile] = useState(false);
|
|
221
221
|
const [showList, setShowList] = useState(true);
|
|
222
222
|
const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
|
|
223
223
|
const [imageReady, setImageReady] = useState(true);
|
|
224
|
-
|
|
224
|
+
// null = not checked yet. Keeping "unknown" distinct from "none configured" is
|
|
225
|
+
// the whole point: `false` as the initial value makes an unanswered check look
|
|
226
|
+
// exactly like a negative answer, so the warning banner renders on no evidence.
|
|
227
|
+
const [providersConfigured, setProvidersConfigured] = useState<boolean | null>(null);
|
|
225
228
|
const [gatewayToken, setGatewayToken] = useState('');
|
|
226
229
|
const [tokenLoading, setTokenLoading] = useState(true);
|
|
227
230
|
const [tokenSaving, setTokenSaving] = useState(false);
|
|
@@ -274,7 +277,8 @@ export default function AgentsPageClient() {
|
|
|
274
277
|
|
|
275
278
|
// Check if any provider is configured in the Gateway
|
|
276
279
|
useEffect(() => {
|
|
277
|
-
|
|
280
|
+
// summary=1: skips the live-pricing round trip we don't need here.
|
|
281
|
+
fetch('/api/gateway/provider?summary=1')
|
|
278
282
|
.then((r) => r.json())
|
|
279
283
|
.then((d: { providers?: Array<{ configured: boolean }> }) =>
|
|
280
284
|
setProvidersConfigured((d.providers || []).some((p) => p.configured))
|
|
@@ -358,8 +362,36 @@ export default function AgentsPageClient() {
|
|
|
358
362
|
return list;
|
|
359
363
|
}, [agents, filter]);
|
|
360
364
|
|
|
365
|
+
// Derive the selected agent from the live list rather than holding a snapshot.
|
|
366
|
+
// A snapshot goes stale on every 15s poll (status, ports, imageOutdated) and
|
|
367
|
+
// survives deletion; deriving means a rename shows up on the next load() and a
|
|
368
|
+
// deleted agent can't leave a detail panel pointing at something that's gone.
|
|
369
|
+
const selected = useMemo(
|
|
370
|
+
() => (selectedId ? agents.find((a) => a.agentId === selectedId) ?? null : null),
|
|
371
|
+
[agents, selectedId],
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// The selection can vanish under us — deleted here, or from another tab/CLI
|
|
375
|
+
// and picked up by the poll. Drop it so mobile falls back to the list instead
|
|
376
|
+
// of rendering a dead detail view.
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (selectedId && !agents.some((a) => a.agentId === selectedId)) {
|
|
379
|
+
setSelectedId(null);
|
|
380
|
+
setShowList(true);
|
|
381
|
+
}
|
|
382
|
+
}, [agents, selectedId]);
|
|
383
|
+
|
|
384
|
+
/** An agent was deleted: drop it optimistically, then reconcile with the server. */
|
|
385
|
+
const handleAgentDeleted = useCallback((agentId: string) => {
|
|
386
|
+
// The DELETE already succeeded server-side, so removing it here is safe and
|
|
387
|
+
// spares the user the round trip — the API tears down container, volume and
|
|
388
|
+
// backups before it answers, which takes seconds.
|
|
389
|
+
setAgents((prev) => prev.filter((a) => a.agentId !== agentId));
|
|
390
|
+
void load();
|
|
391
|
+
}, [load]);
|
|
392
|
+
|
|
361
393
|
function selectAgent(agent: Agent) {
|
|
362
|
-
|
|
394
|
+
setSelectedId(agent.agentId);
|
|
363
395
|
if (isMobile) setShowList(false);
|
|
364
396
|
}
|
|
365
397
|
|
|
@@ -396,7 +428,11 @@ export default function AgentsPageClient() {
|
|
|
396
428
|
</div>
|
|
397
429
|
);
|
|
398
430
|
|
|
399
|
-
|
|
431
|
+
// Hold the skeleton until the provider check has answered too. Both effects
|
|
432
|
+
// fire on mount, so this costs nothing when the check is warm (~3ms) and only
|
|
433
|
+
// shows up on a cold one (~230ms) — where it's exactly what we want, since
|
|
434
|
+
// that's the window the banner used to flash in.
|
|
435
|
+
if (loading || providersConfigured === null) {
|
|
400
436
|
return (
|
|
401
437
|
<div style={{ padding: 14, display: 'grid', gap: 14 }}>
|
|
402
438
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
@@ -433,10 +469,10 @@ export default function AgentsPageClient() {
|
|
|
433
469
|
<span style={{ fontSize: 12, color: 'var(--violet)' }}>{selected.name}</span>
|
|
434
470
|
</div>
|
|
435
471
|
<ImageDownloadBanner onImageReady={handleImageReady} />
|
|
436
|
-
{
|
|
472
|
+
{providersConfigured === false && <NoProviderBanner />}
|
|
437
473
|
{/* Scrollable content */}
|
|
438
474
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
439
|
-
<DetailPanel agent={selected} />
|
|
475
|
+
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
|
|
440
476
|
</div>
|
|
441
477
|
</div>
|
|
442
478
|
);
|
|
@@ -460,7 +496,7 @@ export default function AgentsPageClient() {
|
|
|
460
496
|
))}
|
|
461
497
|
</div>
|
|
462
498
|
<ImageDownloadBanner onImageReady={handleImageReady} />
|
|
463
|
-
{
|
|
499
|
+
{providersConfigured === false && <NoProviderBanner />}
|
|
464
500
|
{tokenSection}
|
|
465
501
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
466
502
|
{filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
|
|
@@ -499,13 +535,15 @@ export default function AgentsPageClient() {
|
|
|
499
535
|
))}
|
|
500
536
|
</div>
|
|
501
537
|
<ImageDownloadBanner onImageReady={handleImageReady} />
|
|
502
|
-
{
|
|
538
|
+
{providersConfigured === false && <NoProviderBanner />}
|
|
503
539
|
{tokenSection}
|
|
504
540
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
|
505
541
|
<div style={{ width: '38%', minWidth: 300, borderRight: '1px solid var(--border)', overflow: 'auto' }}>
|
|
506
542
|
{filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
|
|
507
543
|
{filtered.map((agent) => {
|
|
508
|
-
|
|
544
|
+
// Match on agentId, not the container id: recreate gives the agent a
|
|
545
|
+
// new container (new id) and would otherwise silently deselect it.
|
|
546
|
+
const isSelected = selectedId === agent.agentId;
|
|
509
547
|
|
|
510
548
|
return (
|
|
511
549
|
<AgentListItem
|
|
@@ -514,7 +552,7 @@ export default function AgentsPageClient() {
|
|
|
514
552
|
loading={lifecycleLoading === agent.agentId}
|
|
515
553
|
channelSummary={chSummary}
|
|
516
554
|
selected={isSelected}
|
|
517
|
-
onClick={() =>
|
|
555
|
+
onClick={() => setSelectedId(agent.agentId)}
|
|
518
556
|
onLifecycle={() => handleLifecycle(agent)}
|
|
519
557
|
/>
|
|
520
558
|
);
|
|
@@ -524,7 +562,10 @@ export default function AgentsPageClient() {
|
|
|
524
562
|
{!selected ? (
|
|
525
563
|
<div style={{ color: '#666', fontSize: 12, padding: 20 }}>Select an agent for details</div>
|
|
526
564
|
) : (
|
|
527
|
-
|
|
565
|
+
// key: without it React reuses one DetailPanel instance across
|
|
566
|
+
// selections, so in-flight state (deleting, recreating, restarting,
|
|
567
|
+
// toasts, open confirms) leaks onto the next agent you click.
|
|
568
|
+
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
|
|
528
569
|
)}
|
|
529
570
|
</div>
|
|
530
571
|
</div>
|
|
@@ -533,7 +574,12 @@ export default function AgentsPageClient() {
|
|
|
533
574
|
}
|
|
534
575
|
|
|
535
576
|
// ─── Edit Agent modal ───
|
|
536
|
-
function EditAgentModal({ agent, onClose }: {
|
|
577
|
+
function EditAgentModal({ agent, onClose, onSaved }: {
|
|
578
|
+
agent: Agent;
|
|
579
|
+
onClose: () => void;
|
|
580
|
+
/** Rename succeeded — the parent refetches so the new name propagates. */
|
|
581
|
+
onSaved: () => void;
|
|
582
|
+
}) {
|
|
537
583
|
const [displayName, setDisplayName] = useState(agent.displayName || agent.name);
|
|
538
584
|
const [saving, setSaving] = useState(false);
|
|
539
585
|
const [error, setError] = useState('');
|
|
@@ -558,8 +604,9 @@ function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void
|
|
|
558
604
|
});
|
|
559
605
|
const data = await res.json();
|
|
560
606
|
if (!res.ok) throw new Error(data.error || 'Failed to update');
|
|
561
|
-
//
|
|
562
|
-
|
|
607
|
+
// Refetch the list so the new display name propagates — no full reload.
|
|
608
|
+
onSaved();
|
|
609
|
+
onClose();
|
|
563
610
|
} catch (e: unknown) {
|
|
564
611
|
setError((e as Error).message);
|
|
565
612
|
setSaving(false);
|
|
@@ -682,7 +729,13 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
|
|
|
682
729
|
}
|
|
683
730
|
|
|
684
731
|
// ─── Detail panel (shared between mobile & desktop) ───
|
|
685
|
-
function DetailPanel({ agent
|
|
732
|
+
function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
733
|
+
agent: Agent;
|
|
734
|
+
/** The agent is gone — the parent drops it from the list. */
|
|
735
|
+
onDeleted: (agentId: string) => void;
|
|
736
|
+
/** The agent changed in place (renamed, recreated) — refetch the list. */
|
|
737
|
+
onChanged: () => void;
|
|
738
|
+
}) {
|
|
686
739
|
const [deleting, setDeleting] = useState(false);
|
|
687
740
|
const [deleteError, setDeleteError] = useState('');
|
|
688
741
|
const [showConfig, setShowConfig] = useState(false);
|
|
@@ -693,14 +746,17 @@ function DetailPanel({ agent }: { agent: Agent }) {
|
|
|
693
746
|
setDeleting(true);
|
|
694
747
|
setDeleteError('');
|
|
695
748
|
try {
|
|
696
|
-
const res = await fetch(`/api/agents/${agent.
|
|
749
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, { method: 'DELETE' });
|
|
697
750
|
const data = await res.json();
|
|
698
751
|
if (!res.ok || !data.success) {
|
|
699
752
|
setDeleteError(data.error || 'Delete failed');
|
|
700
753
|
setDeleting(false);
|
|
701
754
|
return;
|
|
702
755
|
}
|
|
703
|
-
|
|
756
|
+
setShowDeleteConfirm(false);
|
|
757
|
+
// No setDeleting(false): the parent drops the agent and this panel
|
|
758
|
+
// unmounts. Clearing state on an unmounted component would warn.
|
|
759
|
+
onDeleted(agent.agentId);
|
|
704
760
|
} catch (e: unknown) {
|
|
705
761
|
setDeleteError((e as Error).message || 'Network error');
|
|
706
762
|
setDeleting(false);
|
|
@@ -796,7 +852,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
|
|
|
796
852
|
{showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
|
|
797
853
|
|
|
798
854
|
{/* Edit agent modal */}
|
|
799
|
-
{showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} />}
|
|
855
|
+
{showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} />}
|
|
800
856
|
|
|
801
857
|
{/* Delete confirmation */}
|
|
802
858
|
<ConfirmModal
|
|
@@ -819,7 +875,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
|
|
|
819
875
|
</div>
|
|
820
876
|
</div>
|
|
821
877
|
)}
|
|
822
|
-
<RecreateSection agentName={agent.name} />
|
|
878
|
+
<RecreateSection agentName={agent.name} onRecreated={onChanged} />
|
|
823
879
|
|
|
824
880
|
{/* Delete */}
|
|
825
881
|
<div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
|
|
@@ -1137,7 +1193,11 @@ function RestartSection({ agentName }: { agentName: string }) {
|
|
|
1137
1193
|
|
|
1138
1194
|
/* ─── Recreate section - rebuild container from same image while keeping volume ─── */
|
|
1139
1195
|
|
|
1140
|
-
function RecreateSection({ agentName }: {
|
|
1196
|
+
function RecreateSection({ agentName, onRecreated }: {
|
|
1197
|
+
agentName: string;
|
|
1198
|
+
/** The container was rebuilt — the parent refetches (new id, fresh image). */
|
|
1199
|
+
onRecreated: () => void;
|
|
1200
|
+
}) {
|
|
1141
1201
|
const [recreating, setRecreating] = useState(false);
|
|
1142
1202
|
const [error, setError] = useState('');
|
|
1143
1203
|
const [toast, setToast] = useState<string | null>(null);
|
|
@@ -1157,7 +1217,9 @@ function RecreateSection({ agentName }: { agentName: string }) {
|
|
|
1157
1217
|
const data = await res.json();
|
|
1158
1218
|
if (data.success) {
|
|
1159
1219
|
showToast(data.backup ? `Recreated (backup: ${data.backup})` : 'Container recreated successfully');
|
|
1160
|
-
|
|
1220
|
+
// Refetch instead of reloading: the toast stays readable and the panel
|
|
1221
|
+
// keeps its scroll position while the new container id comes in.
|
|
1222
|
+
onRecreated();
|
|
1161
1223
|
} else {
|
|
1162
1224
|
setError(data.error || 'Recreate failed');
|
|
1163
1225
|
}
|
|
@@ -44,11 +44,23 @@ const DEFAULT_BLOCK_SIZE = 10;
|
|
|
44
44
|
|
|
45
45
|
function getUsedHostPorts(): Set<number> {
|
|
46
46
|
try {
|
|
47
|
+
// Parse host ports in JS rather than piping through grep. The previous
|
|
48
|
+
// form used `grep -oP '\d+(?=->)'` inside a template literal, where \d
|
|
49
|
+
// loses its backslash — the shell received `d+(?=->)`, which never
|
|
50
|
+
// matches Docker's `0.0.0.0:3700->3000/tcp`, so this always returned an
|
|
51
|
+
// empty set and every agent was offered port 3700. `grep -P` is also
|
|
52
|
+
// GNU-only and absent on macOS. This mirrors the client-side parser in
|
|
53
|
+
// app/agents/create/page.tsx.
|
|
47
54
|
const raw = execSync(
|
|
48
|
-
`docker ps --format '{{.Ports}}'
|
|
55
|
+
`docker ps --format '{{.Ports}}'`,
|
|
49
56
|
{ encoding: 'utf-8', timeout: 3000 },
|
|
50
|
-
)
|
|
51
|
-
|
|
57
|
+
);
|
|
58
|
+
const used = new Set<number>();
|
|
59
|
+
for (const [, port] of raw.matchAll(/(\d+)->/g)) {
|
|
60
|
+
const n = Number(port);
|
|
61
|
+
if (Number.isInteger(n)) used.add(n);
|
|
62
|
+
}
|
|
63
|
+
return used;
|
|
52
64
|
} catch {
|
|
53
65
|
return new Set();
|
|
54
66
|
}
|
|
@@ -16,6 +16,8 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
|
16
16
|
export const dynamic = 'force-dynamic';
|
|
17
17
|
|
|
18
18
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
19
|
+
const denied = await requireAuthJWT(request);
|
|
20
|
+
if (denied) return denied;
|
|
19
21
|
if (getIsDownloading()) {
|
|
20
22
|
return NextResponse.json(
|
|
21
23
|
{ error: 'A download is already in progress' },
|
|
@@ -71,6 +71,8 @@ function getRemoteDigest(): string | null {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
74
|
+
const denied = await requireAuthJWT(request);
|
|
75
|
+
if (denied) return denied;
|
|
74
76
|
const exists = localImageExists();
|
|
75
77
|
const localId = getLocalImageId();
|
|
76
78
|
const downloading = getIsDownloading();
|
package/app/api/agents/route.ts
CHANGED
|
@@ -1,48 +1,13 @@
|
|
|
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
4
|
import { detectPublicHost } from '@/lib/docker-utils';
|
|
6
5
|
import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
|
|
7
6
|
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
|
-
}
|
|
7
|
+
import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
|
|
34
8
|
|
|
35
9
|
export const dynamic = 'force-dynamic';
|
|
36
10
|
|
|
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
11
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
47
12
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
48
13
|
// If Docker is not available (e.g. testing environment), return empty list
|
|
@@ -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];
|