@flame0510/project-aether 1.1.9
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 +150 -0
- package/agent-templates/README.md +37 -0
- package/agent-templates/argus/AGENTS.md +52 -0
- package/agent-templates/argus/IDENTITY.md +7 -0
- package/agent-templates/argus/MEMORY.md +9 -0
- package/agent-templates/argus/SOUL.md +24 -0
- package/agent-templates/argus/TOOLS.md +8 -0
- package/agent-templates/atlas/AGENTS.md +47 -0
- package/agent-templates/atlas/HEARTBEAT.md +10 -0
- package/agent-templates/atlas/IDENTITY.md +6 -0
- package/agent-templates/atlas/MEMORY.md +9 -0
- package/agent-templates/atlas/SOUL.md +21 -0
- package/agent-templates/atlas/TOOLS.md +4 -0
- package/agent-templates/base-image/.trigger +0 -0
- package/agent-templates/base-image/Dockerfile +90 -0
- package/agent-templates/base-image/entrypoint.sh +37 -0
- package/agent-templates/custom/AGENTS.md +29 -0
- package/agent-templates/custom/BOOTSTRAP.md +56 -0
- package/agent-templates/custom/MEMORY.md +8 -0
- package/agent-templates/prometheus/AGENTS.md +37 -0
- package/agent-templates/prometheus/IDENTITY.md +6 -0
- package/agent-templates/prometheus/MEMORY.md +9 -0
- package/agent-templates/prometheus/SOUL.md +21 -0
- package/agent-templates/prometheus/TOOLS.md +4 -0
- package/app/agents/ChannelManager.tsx +465 -0
- package/app/agents/ImageDownloadBanner.tsx +159 -0
- package/app/agents/PageClient.tsx +1285 -0
- package/app/agents/create/PageClient.tsx +437 -0
- package/app/agents/create/page.tsx +126 -0
- package/app/agents/loading.tsx +14 -0
- package/app/agents/page.tsx +5 -0
- package/app/api/agents/[id]/backup/route.ts +169 -0
- package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
- package/app/api/agents/[id]/channels/route.ts +14 -0
- package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
- package/app/api/agents/[id]/lifecycle/route.ts +94 -0
- package/app/api/agents/[id]/recreate/route.ts +199 -0
- package/app/api/agents/[id]/restart/route.ts +33 -0
- package/app/api/agents/[id]/restore/route.ts +81 -0
- package/app/api/agents/[id]/route.ts +280 -0
- package/app/api/agents/channels-summary/route.ts +39 -0
- package/app/api/agents/config-json/route.ts +58 -0
- package/app/api/agents/create/route.ts +488 -0
- package/app/api/agents/download-image/route.ts +32 -0
- package/app/api/agents/image-status/route.ts +126 -0
- package/app/api/agents/route.ts +213 -0
- package/app/api/agents/token/route.ts +94 -0
- package/app/api/agents-active/route.ts +190 -0
- package/app/api/agents-config/route.ts +629 -0
- package/app/api/alerts/smoke/route.ts +26 -0
- package/app/api/aliases/add/route.ts +28 -0
- package/app/api/aliases/remove/route.ts +27 -0
- package/app/api/assistant/route.ts +204 -0
- package/app/api/auth/check/route.ts +38 -0
- package/app/api/auth/login/route.ts +85 -0
- package/app/api/auth/logout/route.ts +7 -0
- package/app/api/auth/status/route.ts +33 -0
- package/app/api/config/env/route.ts +132 -0
- package/app/api/config/restart/route.ts +26 -0
- package/app/api/containers/route.ts +75 -0
- package/app/api/cost-override/route.ts +46 -0
- package/app/api/costs/route.ts +84 -0
- package/app/api/credentials/[id]/reveal/route.ts +21 -0
- package/app/api/credentials/[id]/route.ts +52 -0
- package/app/api/credentials/[id]/sync/route.ts +58 -0
- package/app/api/credentials/detect/route.ts +246 -0
- package/app/api/credentials/route.ts +33 -0
- package/app/api/crons/[id]/route.ts +69 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/crons/runs/route.ts +31 -0
- package/app/api/debug/route.ts +11 -0
- package/app/api/envcheck/route.ts +18 -0
- package/app/api/events/route.ts +24 -0
- package/app/api/gateway/agent/route.ts +154 -0
- package/app/api/gateway/provider/balance/route.ts +38 -0
- package/app/api/gateway/provider/keys.ts +38 -0
- package/app/api/gateway/provider/oauth/route.ts +304 -0
- package/app/api/gateway/provider/route.ts +209 -0
- package/app/api/gateway/route.ts +223 -0
- package/app/api/gateway/sync.ts +189 -0
- package/app/api/lineage/route.ts +54 -0
- package/app/api/memory-context/route.ts +15 -0
- package/app/api/metrics/route.ts +35 -0
- package/app/api/models/route.ts +77 -0
- package/app/api/plugins/route.js +37 -0
- package/app/api/provider/upstream.ts +142 -0
- package/app/api/provider/v1/chat/completions/route.ts +146 -0
- package/app/api/provider/v1/models/route.ts +167 -0
- package/app/api/provider-usage/route.ts +629 -0
- package/app/api/session/route.ts +39 -0
- package/app/api/sessions/[id]/route.ts +38 -0
- package/app/api/sessions/route.ts +23 -0
- package/app/api/setup/agent-image/route.ts +102 -0
- package/app/api/setup/password/route.ts +111 -0
- package/app/api/setup/restart/route.ts +76 -0
- package/app/api/skills/delete/route.js +105 -0
- package/app/api/skills/promote/route.js +129 -0
- package/app/api/skills/route.js +203 -0
- package/app/api/skills/save/route.js +142 -0
- package/app/api/stats/route.ts +34 -0
- package/app/api/stats-since/route.ts +20 -0
- package/app/api/stream/route.ts +70 -0
- package/app/api/system-health/route.ts +233 -0
- package/app/api/tool-calls/route.ts +24 -0
- package/app/api/tools-config/route.ts +163 -0
- package/app/api/update-check/route.ts +127 -0
- package/app/api/version/route.ts +25 -0
- package/app/api/wizard/complete/route.ts +32 -0
- package/app/api/wizard/reset/route.ts +28 -0
- package/app/api/wizard/status/route.ts +28 -0
- package/app/api/workspace/route.ts +392 -0
- package/app/api/workspace/stream/route.ts +136 -0
- package/app/components/AppShell.tsx +33 -0
- package/app/components/AuthGuard.tsx +91 -0
- package/app/components/CostBreakdown.tsx +37 -0
- package/app/components/DashboardHeader.tsx +73 -0
- package/app/components/DashboardLayout.tsx +76 -0
- package/app/components/DashboardToolbar.tsx +61 -0
- package/app/components/FloatingActions.tsx +33 -0
- package/app/components/Icons.tsx +78 -0
- package/app/components/LineageGraphPage.tsx +142 -0
- package/app/components/LiveFeed.tsx +48 -0
- package/app/components/MobileBottomNav.tsx +84 -0
- package/app/components/ModelPickerModal.tsx +94 -0
- package/app/components/PasswordInput.tsx +86 -0
- package/app/components/PulseChat.tsx +362 -0
- package/app/components/Rev4aLoader.tsx +48 -0
- package/app/components/SessionDrawer.tsx +263 -0
- package/app/components/SessionTopology.tsx +289 -0
- package/app/components/Sidebar.tsx +294 -0
- package/app/components/Skeleton.tsx +133 -0
- package/app/components/SystemCockpit.tsx +169 -0
- package/app/components/VersionBanner.tsx +105 -0
- package/app/components/WizardButton.tsx +30 -0
- package/app/components/ui/Badge.tsx +30 -0
- package/app/components/ui/Button.tsx +59 -0
- package/app/components/ui/ConfirmModal.tsx +46 -0
- package/app/components/ui/FilterBar.tsx +33 -0
- package/app/components/ui/Input.tsx +25 -0
- package/app/components/ui/ItemList.tsx +83 -0
- package/app/components/ui/LoadingSpinner.tsx +30 -0
- package/app/components/ui/Metric.tsx +22 -0
- package/app/components/ui/Modal.tsx +42 -0
- package/app/components/ui/Page.tsx +18 -0
- package/app/components/ui/Pill.tsx +6 -0
- package/app/components/ui/PropertyList.tsx +24 -0
- package/app/components/ui/Select.tsx +26 -0
- package/app/components/ui/StatusCard.tsx +32 -0
- package/app/components/ui/Surface.tsx +14 -0
- package/app/components/ui/Tabs.tsx +55 -0
- package/app/components/ui/TemplateOption.tsx +59 -0
- package/app/components/ui/Toast.tsx +44 -0
- package/app/components/ui/index.ts +19 -0
- package/app/components/ui/tokens.ts +18 -0
- package/app/config/PageClient.tsx +293 -0
- package/app/config/loading.tsx +10 -0
- package/app/config/page.tsx +7 -0
- package/app/containers/ContainersClient.tsx +129 -0
- package/app/containers/loading.tsx +21 -0
- package/app/containers/page.tsx +5 -0
- package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
- package/app/containers/terminal/[id]/page.tsx +6 -0
- package/app/credentials/PageClient.tsx +598 -0
- package/app/credentials/loading.tsx +10 -0
- package/app/credentials/page.tsx +7 -0
- package/app/crons/PageClient.tsx +538 -0
- package/app/crons/loading.tsx +10 -0
- package/app/crons/page.tsx +5 -0
- package/app/design-system.ts +57 -0
- package/app/gateway/PageClient.tsx +1048 -0
- package/app/gateway/loading.tsx +24 -0
- package/app/gateway/page.tsx +5 -0
- package/app/globals.css +1474 -0
- package/app/layout.tsx +45 -0
- package/app/lib/models-context.tsx +41 -0
- package/app/lineage/loading.tsx +10 -0
- package/app/lineage/page.tsx +13 -0
- package/app/loading.tsx +16 -0
- package/app/login/page.tsx +160 -0
- package/app/memory/MemoryContextPageClient.tsx +196 -0
- package/app/memory/loading.tsx +10 -0
- package/app/memory/page.tsx +8 -0
- package/app/page.tsx +31 -0
- package/app/plugins/PageClient.tsx +296 -0
- package/app/plugins/loading.tsx +10 -0
- package/app/plugins/page.tsx +5 -0
- package/app/setup/PageClient.tsx +227 -0
- package/app/setup/loading.tsx +21 -0
- package/app/setup/page.tsx +7 -0
- package/app/skills/PageClient.tsx +499 -0
- package/app/skills/loading.tsx +10 -0
- package/app/skills/page.tsx +5 -0
- package/app/tools/ToolsPageClient.tsx +308 -0
- package/app/tools/loading.tsx +10 -0
- package/app/tools/page.tsx +62 -0
- package/app/tools/tools-catalog.ts +141 -0
- package/app/wizard/PageClient.tsx +485 -0
- package/app/wizard/icons.tsx +89 -0
- package/app/wizard/loading.tsx +24 -0
- package/app/wizard/page.tsx +7 -0
- package/app/wizard/storage.ts +33 -0
- package/app/wizard/useWizard.ts +101 -0
- package/app/workspace/WorkspaceClient.tsx +633 -0
- package/app/workspace/loading.tsx +36 -0
- package/app/workspace/page.tsx +8 -0
- package/bin/docker-entrypoint.sh +21 -0
- package/bin/postinstall.js +20 -0
- package/bin/rev4a.js +696 -0
- package/daemon.js +783 -0
- package/docs/ARCHITECTURE.md +557 -0
- package/docs/CONTAINER-TERMINAL.md +280 -0
- package/docs/DESIGN-SYSTEM.md +99 -0
- package/docs/FRONTEND-ARCHITECTURE.md +119 -0
- package/docs/REV4A.md +504 -0
- package/docs/dev/API-REFERENCE.md +1835 -0
- package/docs/dev/DATABASE.md +224 -0
- package/docs/dev/GATEWAY.md +300 -0
- package/docs/dev/PROVIDERS.md +229 -0
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
- package/docs/dev/WORKSPACE.md +199 -0
- package/docs/rag/DATA-FRESHNESS.md +109 -0
- package/docs/rag/GLOSSARY.md +125 -0
- package/docs/rag/REV4A-OVERVIEW.md +135 -0
- package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
- package/lib/agent-setup.ts +130 -0
- package/lib/alerts.ts +115 -0
- package/lib/apiFetch.ts +7 -0
- package/lib/auth.ts +28 -0
- package/lib/billing.ts +100 -0
- package/lib/buildAgentImage.ts +346 -0
- package/lib/channelManager.ts +386 -0
- package/lib/container.ts +60 -0
- package/lib/credentials/db.ts +86 -0
- package/lib/credentials/delivery.ts +448 -0
- package/lib/credentials/detect.ts +149 -0
- package/lib/credentials/hash.ts +9 -0
- package/lib/credentials/providers.ts +110 -0
- package/lib/credentials/vault.ts +195 -0
- package/lib/db-bootstrap.d.ts +3 -0
- package/lib/db-bootstrap.mjs +144 -0
- package/lib/db.ts +25 -0
- package/lib/docker-utils.ts +60 -0
- package/lib/hooks/useDashboard.ts +264 -0
- package/lib/hooks/useRev4aTimezone.ts +28 -0
- package/lib/hooks/useScreenshot.ts +30 -0
- package/lib/memory-context.ts +385 -0
- package/lib/model-catalogue.ts +131 -0
- package/lib/model-pricing.ts +63 -0
- package/lib/openclaw-cron.ts +350 -0
- package/lib/patterns/ApiAdapter.ts +73 -0
- package/lib/patterns/ApiClient.ts +65 -0
- package/lib/patterns/EventBus.ts +113 -0
- package/lib/patterns/FilterStrategy.ts +79 -0
- package/lib/patterns/SessionFactory.ts +117 -0
- package/lib/patterns/sessionPresentation.ts +117 -0
- package/lib/provider-balance.ts +163 -0
- package/lib/requireAuth.tsx +38 -0
- package/lib/rev4a-auth.d.ts +3 -0
- package/lib/rev4a-auth.js +36 -0
- package/lib/rev4a-paths.ts +145 -0
- package/lib/timezone.ts +46 -0
- package/lib/types/index.ts +96 -0
- package/lib/utils/format.ts +82 -0
- package/lineage.js +56 -0
- package/model-pricing.json +482 -0
- package/models.config.json +844 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +7 -0
- package/package.json +82 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.svg +58 -0
- package/public/icon-192-maskable.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512-maskable.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +38 -0
- package/public/rev4a-logo.png +0 -0
- package/public/rev4a-logo.svg +58 -0
- package/rev4a-rules/AGENTS.md +37 -0
- package/scripts/add-auth.cjs +185 -0
- package/scripts/backup.sh +66 -0
- package/scripts/check-providers.mjs +143 -0
- package/scripts/docker/traefik/docker-compose.yml +32 -0
- package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
- package/scripts/docker/traefik/traefik.yml +37 -0
- package/scripts/remove-auth-profile.js +56 -0
- package/scripts/restore.sh +82 -0
- package/terminal-ws-server.js +139 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { buildRev4aProviderConfig } from '../../gateway/sync';
|
|
7
|
+
import { detectPublicHost } from '@/lib/docker-utils';
|
|
8
|
+
import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
|
|
9
|
+
import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
|
|
10
|
+
import { loadModelsConfig } from '@/lib/model-catalogue';
|
|
11
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wait for a Docker container gateway to be ready.
|
|
15
|
+
* Checks that the gateway is responding on port 3000 inside the container.
|
|
16
|
+
* Polls every 2s, gives up after timeoutMs.
|
|
17
|
+
* Returns true if the gateway is reachable, false on timeout.
|
|
18
|
+
*/
|
|
19
|
+
function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
while (Date.now() - start < timeoutMs) {
|
|
22
|
+
try {
|
|
23
|
+
execSync(
|
|
24
|
+
`docker exec ${name} sh -c 'curl -sf http://127.0.0.1:3000/health >/dev/null 2>&1 || wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1'`,
|
|
25
|
+
{ timeout: 5000 },
|
|
26
|
+
);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
// gateway not ready yet
|
|
30
|
+
}
|
|
31
|
+
// Sleep 2s
|
|
32
|
+
execSync('sleep 2', { timeout: 3000 });
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find an available host port block starting from 3700.
|
|
41
|
+
*/
|
|
42
|
+
const REV4A_PORT = 3740;
|
|
43
|
+
const DEFAULT_BLOCK_SIZE = 10;
|
|
44
|
+
|
|
45
|
+
function getUsedHostPorts(): Set<number> {
|
|
46
|
+
try {
|
|
47
|
+
const raw = execSync(
|
|
48
|
+
`docker ps --format '{{.Ports}}' | grep -oP '\d+(?=->)' | sort -n`,
|
|
49
|
+
{ encoding: 'utf-8', timeout: 3000 },
|
|
50
|
+
).trim();
|
|
51
|
+
return new Set(raw ? raw.split('\n').map(Number) : []);
|
|
52
|
+
} catch {
|
|
53
|
+
return new Set();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findAvailablePortBlock(blockSize = DEFAULT_BLOCK_SIZE): number {
|
|
58
|
+
const usedPorts = getUsedHostPorts();
|
|
59
|
+
for (let base = 3700; base + blockSize - 1 <= 3799; base += DEFAULT_BLOCK_SIZE) {
|
|
60
|
+
if (base <= REV4A_PORT && REV4A_PORT <= base + blockSize - 1) continue;
|
|
61
|
+
const block = Array.from({ length: blockSize }, (_, i) => base + i);
|
|
62
|
+
if (block.every((p) => !usedPorts.has(p))) return base;
|
|
63
|
+
}
|
|
64
|
+
let base = 3800;
|
|
65
|
+
while (true) {
|
|
66
|
+
const block = Array.from({ length: blockSize }, (_, i) => base + i);
|
|
67
|
+
if (block.every((p) => !usedPorts.has(p))) return base;
|
|
68
|
+
base += DEFAULT_BLOCK_SIZE;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validatePortInput(input: string): { valid: true; isBlock: boolean; start: number; end: number } | { valid: false; error: string } {
|
|
73
|
+
const trimmed = input.trim();
|
|
74
|
+
if (trimmed.includes('-')) {
|
|
75
|
+
const m = trimmed.match(/^(\d{1,5})-(\d{1,5})$/);
|
|
76
|
+
if (!m) return { valid: false, error: 'Invalid format. Use e.g. 3700-3709' };
|
|
77
|
+
const start = parseInt(m[1], 10);
|
|
78
|
+
const end = parseInt(m[2], 10);
|
|
79
|
+
if (start < 1 || end > 65535) return { valid: false, error: 'Ports must be between 1 and 65535' };
|
|
80
|
+
if (end <= start) return { valid: false, error: 'End port must be greater than start port' };
|
|
81
|
+
if (start <= REV4A_PORT && REV4A_PORT <= end) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
|
|
82
|
+
const usedPorts = getUsedHostPorts();
|
|
83
|
+
const conflicts: number[] = [];
|
|
84
|
+
for (let p = start; p <= end; p++) { if (usedPorts.has(p)) conflicts.push(p); }
|
|
85
|
+
if (conflicts.length > 0) return { valid: false, error: `Ports already in use: ${conflicts.join(', ')}` };
|
|
86
|
+
return { valid: true, isBlock: true, start, end };
|
|
87
|
+
}
|
|
88
|
+
const m = trimmed.match(/^(\d{1,5})$/);
|
|
89
|
+
if (!m) return { valid: false, error: 'Use a number (e.g. 3700) or a range (e.g. 3700-3709)' };
|
|
90
|
+
const port = parseInt(m[1], 10);
|
|
91
|
+
if (port < 1 || port > 65535) return { valid: false, error: 'Port must be between 1 and 65535' };
|
|
92
|
+
if (port === REV4A_PORT) return { valid: false, error: `Port ${REV4A_PORT} is reserved for Rev4a` };
|
|
93
|
+
const usedPorts = getUsedHostPorts();
|
|
94
|
+
if (usedPorts.has(port)) return { valid: false, error: `Port ${port} is already in use` };
|
|
95
|
+
return { valid: true, isBlock: false, start: port, end: port };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const dynamic = 'force-dynamic';
|
|
99
|
+
export const maxDuration = 180; // docker run (60s) + health check (60s) + seeding (60s)
|
|
100
|
+
|
|
101
|
+
const TEMPLATES_DIR = templatesDir(process.cwd());
|
|
102
|
+
const AGENTS_TOKEN_PATH = AGENTS_TOKEN_FILE;
|
|
103
|
+
const DEFAULT_NETWORK = 'rev4a-network';
|
|
104
|
+
|
|
105
|
+
function isDockerCompatibleName(name: string): boolean {
|
|
106
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function generateContainerName(): string {
|
|
110
|
+
const hex = crypto.randomBytes(4).toString('hex');
|
|
111
|
+
return `agent_${hex}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface CreateBody {
|
|
115
|
+
name: string;
|
|
116
|
+
template: string;
|
|
117
|
+
portRange?: string;
|
|
118
|
+
model?: string;
|
|
119
|
+
fallbacks?: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shellQuote(value: string): string {
|
|
123
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readAgentsToken(): string {
|
|
127
|
+
try {
|
|
128
|
+
const tokenRaw = fs.readFileSync(AGENTS_TOKEN_PATH, 'utf-8');
|
|
129
|
+
const tokenData = JSON.parse(tokenRaw);
|
|
130
|
+
return typeof tokenData.token === 'string' ? tokenData.token.trim() : '';
|
|
131
|
+
} catch {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
137
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
138
|
+
try {
|
|
139
|
+
let body: CreateBody;
|
|
140
|
+
try {
|
|
141
|
+
body = await request.json();
|
|
142
|
+
} catch {
|
|
143
|
+
return NextResponse.json(
|
|
144
|
+
{ success: false, error: 'Invalid JSON body' },
|
|
145
|
+
{ status: 400 },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { name: displayName, template, portRange: portInput, model, fallbacks } = body;
|
|
150
|
+
|
|
151
|
+
// Validate display name
|
|
152
|
+
if (!displayName || displayName.trim().length < 1) {
|
|
153
|
+
return NextResponse.json(
|
|
154
|
+
{ success: false, error: 'Agent name is required' },
|
|
155
|
+
{ status: 400 },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Generate a unique container name (stable, never changes)
|
|
160
|
+
const containerName = generateContainerName();
|
|
161
|
+
|
|
162
|
+
// Validate template — must be a valid identifier (no path traversal).
|
|
163
|
+
if (!isDockerCompatibleName(template)) {
|
|
164
|
+
return NextResponse.json(
|
|
165
|
+
{ success: false, error: 'Invalid template name' },
|
|
166
|
+
{ status: 400 },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate template directory exists
|
|
171
|
+
const templateDir = path.join(TEMPLATES_DIR, template);
|
|
172
|
+
if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) {
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
{
|
|
175
|
+
success: false,
|
|
176
|
+
error: `Template '${template}' not found. Available: ${getAvailableTemplates().join(', ')}`,
|
|
177
|
+
},
|
|
178
|
+
{ status: 400 },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Validate model (if provided) exists in models.config.json
|
|
183
|
+
if (model && !isValidModel(model)) {
|
|
184
|
+
return NextResponse.json(
|
|
185
|
+
{ success: false, error: `Model '${model}' not found in models.config.json` },
|
|
186
|
+
{ status: 400 },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if name is already in use by a running container
|
|
191
|
+
const existing = execSync(
|
|
192
|
+
`docker ps --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
|
|
193
|
+
{ encoding: 'utf-8', timeout: 5000 },
|
|
194
|
+
).trim();
|
|
195
|
+
if (existing) {
|
|
196
|
+
return NextResponse.json(
|
|
197
|
+
{ success: false, error: `Agent '${displayName}' is already running (container: ${existing})` },
|
|
198
|
+
{ status: 409 },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Resolve port range
|
|
203
|
+
let isBlock = false;
|
|
204
|
+
let portStart: number = 0;
|
|
205
|
+
let portEnd: number = 0;
|
|
206
|
+
|
|
207
|
+
if (portInput) {
|
|
208
|
+
const v = validatePortInput(portInput);
|
|
209
|
+
if (!v.valid) {
|
|
210
|
+
return NextResponse.json(
|
|
211
|
+
{ success: false, error: v.error },
|
|
212
|
+
{ status: 400 },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
isBlock = v.isBlock;
|
|
216
|
+
portStart = v.start;
|
|
217
|
+
portEnd = v.end;
|
|
218
|
+
} else {
|
|
219
|
+
portStart = findAvailablePortBlock();
|
|
220
|
+
portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
|
|
221
|
+
isBlock = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Ensure shared volumes exist (created by rev4a serve preflight, defensive guard here)
|
|
225
|
+
const mountFlags = getMountFlags();
|
|
226
|
+
|
|
227
|
+
// Build docker run command
|
|
228
|
+
const image = 'openclaw-agent-base:latest';
|
|
229
|
+
const network = getNetwork();
|
|
230
|
+
|
|
231
|
+
// Read agents token for new containers
|
|
232
|
+
const agentsToken = readAgentsToken();
|
|
233
|
+
if (!agentsToken) {
|
|
234
|
+
return NextResponse.json(
|
|
235
|
+
{ success: false, error: 'Agents gateway token is missing. Set it from /agents before creating agents.' },
|
|
236
|
+
{ status: 400 },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Every agent gets a host port range for direct access (http://<ip>:<port>).
|
|
241
|
+
const agentHostname = `${detectPublicHost()}:${portStart}`;
|
|
242
|
+
const traefikUrl = `http://${agentHostname}#token=${encodeURIComponent(agentsToken)}`;
|
|
243
|
+
|
|
244
|
+
const labels: string[] = [`AGENT_ID=${containerName}`];
|
|
245
|
+
const envVars: string[] = [
|
|
246
|
+
`AGENT_ID=${containerName}`,
|
|
247
|
+
`AGENT_NAME=${displayName.trim()}`,
|
|
248
|
+
`AGENT_TEMPLATE=${template}`,
|
|
249
|
+
`OPENCLAW_GATEWAY_TOKEN=${agentsToken}`,
|
|
250
|
+
`AGENT_HOSTNAME=${agentHostname}`,
|
|
251
|
+
`TZ=Europe/Rome`,
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const labelOpts = labels.map((l) => `-l ${shellQuote(l)}`).join(' ');
|
|
255
|
+
const envOpts = envVars.map((e) => `-e ${shellQuote(e)}`).join(' ');
|
|
256
|
+
const portOpt = isBlock
|
|
257
|
+
? `-p ${portStart}-${portEnd}:3000-${3000 + (portEnd - portStart)}`
|
|
258
|
+
: `-p ${portStart}:3000`;
|
|
259
|
+
|
|
260
|
+
// Template files that will be copied into the container workspace.
|
|
261
|
+
// Files that don't exist in the template dir are silently skipped —
|
|
262
|
+
// this is intentional (e.g. BOOTSTRAP.md only exists in custom/).
|
|
263
|
+
const templateFiles = ['AGENTS.md', 'SOUL.md', 'IDENTITY.md', 'MEMORY.md', 'TOOLS.md', 'HEARTBEAT.md', 'BOOTSTRAP.md'];
|
|
264
|
+
|
|
265
|
+
const cmd = `docker run -d \
|
|
266
|
+
--name "${containerName}" \
|
|
267
|
+
--network "${network}" \
|
|
268
|
+
--restart unless-stopped \
|
|
269
|
+
--add-host host.docker.internal:host-gateway \
|
|
270
|
+
-v agent-${containerName}-data:/root \
|
|
271
|
+
${mountFlags.join(' \
|
|
272
|
+
')} \
|
|
273
|
+
${labelOpts} \
|
|
274
|
+
${envOpts} \
|
|
275
|
+
${portOpt} \
|
|
276
|
+
"${image}"`;
|
|
277
|
+
|
|
278
|
+
const result = execSync(cmd, { encoding: 'utf-8', timeout: 60000 });
|
|
279
|
+
const containerId = result.trim();
|
|
280
|
+
|
|
281
|
+
// Log event to events.db if it exists
|
|
282
|
+
try {
|
|
283
|
+
const Database = require('better-sqlite3');
|
|
284
|
+
const dbPath = DB_FILE;
|
|
285
|
+
if (fs.existsSync(dbPath)) {
|
|
286
|
+
const db = new Database(dbPath, { readonly: false });
|
|
287
|
+
db.pragma('journal_mode = WAL');
|
|
288
|
+
db.prepare(
|
|
289
|
+
`INSERT INTO events (ts, session_id, type, data) VALUES (?, ?, ?, ?)`,
|
|
290
|
+
).run(Date.now(), `system:agents`, 'agent_created', JSON.stringify({
|
|
291
|
+
name: displayName,
|
|
292
|
+
template,
|
|
293
|
+
port: portStart,
|
|
294
|
+
portEnd,
|
|
295
|
+
isBlock,
|
|
296
|
+
model: model || 'rev4a/deepseek-v4-flash',
|
|
297
|
+
fallbacks,
|
|
298
|
+
containerId,
|
|
299
|
+
containerName,
|
|
300
|
+
}));
|
|
301
|
+
db.close();
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// DB logging is optional, ignore failures
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Wait for container to be ready (HTTP health check, up to 60s).
|
|
308
|
+
// If it doesn't come up, remove the container and return an error —
|
|
309
|
+
// a zombie agent with no config is worse than an explicit failure.
|
|
310
|
+
const ready = waitForContainerReady(containerName);
|
|
311
|
+
if (!ready) {
|
|
312
|
+
try { execSync(`docker rm -f ${containerName}`, { timeout: 10000 }); } catch { /* best-effort */ }
|
|
313
|
+
return NextResponse.json(
|
|
314
|
+
{ success: false, error: `Container started but gateway did not become healthy within 60s. Container removed — try again.` },
|
|
315
|
+
{ status: 500 },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Container is up, now seed the workspace and config ──
|
|
320
|
+
try {
|
|
321
|
+
// Create workspace dir
|
|
322
|
+
execSync(
|
|
323
|
+
`docker exec ${containerName} mkdir -p /root/.openclaw/workspace`,
|
|
324
|
+
{ timeout: 10000, stdio: 'pipe' },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Copy template files into the container workspace.
|
|
328
|
+
// Skip files that already exist (user may have edited them on a prior
|
|
329
|
+
// deployment — the volume persists across recreates).
|
|
330
|
+
for (const tf of templateFiles) {
|
|
331
|
+
const src = path.join(templateDir, tf);
|
|
332
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
|
|
333
|
+
// Check if the file already exists in the container workspace
|
|
334
|
+
try {
|
|
335
|
+
execSync(
|
|
336
|
+
`docker exec ${containerName} test -f /root/.openclaw/workspace/${tf}`,
|
|
337
|
+
{ timeout: 5000, stdio: 'pipe' },
|
|
338
|
+
);
|
|
339
|
+
// File exists — skip to preserve user edits
|
|
340
|
+
continue;
|
|
341
|
+
} catch {
|
|
342
|
+
// File does not exist — copy it
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
execSync(
|
|
346
|
+
`docker cp ${shellQuote(src)} ${containerName}:/root/.openclaw/workspace/`,
|
|
347
|
+
{ timeout: 10000, stdio: 'pipe' },
|
|
348
|
+
);
|
|
349
|
+
} catch (cpErr) {
|
|
350
|
+
console.warn(`[create] Failed to copy ${tf} to ${containerName}:`, (cpErr as Error).message);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Read the config OpenClaw generated on first boot
|
|
355
|
+
const raw = execSync(
|
|
356
|
+
`docker exec ${containerName} cat /root/.openclaw/openclaw.json`,
|
|
357
|
+
{ timeout: 8000, encoding: 'utf-8' },
|
|
358
|
+
).trim();
|
|
359
|
+
const config = JSON.parse(raw);
|
|
360
|
+
|
|
361
|
+
// Merge all required sections in one pass (single write, single restart)
|
|
362
|
+
|
|
363
|
+
// controlUi — allow browser Control UI access.
|
|
364
|
+
// deviceAuth is disabled because agent containers are only reachable
|
|
365
|
+
// on the internal Docker network; the gateway token is the sole auth barrier.
|
|
366
|
+
const gatewaySection = (config.gateway || {}) as Record<string, unknown>;
|
|
367
|
+
gatewaySection.controlUi = {
|
|
368
|
+
allowedOrigins: ['*'],
|
|
369
|
+
dangerouslyDisableDeviceAuth: true,
|
|
370
|
+
};
|
|
371
|
+
config.gateway = gatewaySection;
|
|
372
|
+
|
|
373
|
+
// Model reference (primary + fallbacks)
|
|
374
|
+
if (model) {
|
|
375
|
+
const modelRef = model.startsWith('rev4a/') ? model : `rev4a/${model}`;
|
|
376
|
+
const fallbackRefs = (fallbacks || []).map((fb: string) =>
|
|
377
|
+
fb.startsWith('rev4a/') ? fb : `rev4a/${fb}`,
|
|
378
|
+
);
|
|
379
|
+
const agents = (config.agents || {}) as Record<string, unknown>;
|
|
380
|
+
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
|
381
|
+
defaults.model = {
|
|
382
|
+
primary: modelRef,
|
|
383
|
+
fallbacks: fallbackRefs.filter((fb: string) => fb !== modelRef),
|
|
384
|
+
};
|
|
385
|
+
agents.defaults = defaults;
|
|
386
|
+
config.agents = agents;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Disable update check (agents are managed through Rev4a)
|
|
390
|
+
config.update = {
|
|
391
|
+
checkOnStart: false,
|
|
392
|
+
auto: { enabled: false },
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Merge model + update + gateway settings (create-specific, not in applyRuntimeConfig)
|
|
396
|
+
|
|
397
|
+
// Single write
|
|
398
|
+
execSync(
|
|
399
|
+
`docker exec -i ${containerName} sh -c 'cat > /root/.openclaw/openclaw.json'`,
|
|
400
|
+
{ timeout: 15000, input: JSON.stringify(config, null, 2) },
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// Single restart to pick up the merged config
|
|
404
|
+
try {
|
|
405
|
+
execSync(`docker exec ${containerName} openclaw gateway restart`, { timeout: 15000 });
|
|
406
|
+
} catch {
|
|
407
|
+
// restart failure is non-fatal; config is on disk, picked up on next boot
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// post-creation config is non-fatal; can be synced manually
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Guarantee Rev4a runtime config (hooks, shared-skills, provider).
|
|
414
|
+
// These patches are idempotent deep-merges — applied AFTER the
|
|
415
|
+
// create-specific config so they're always present regardless of
|
|
416
|
+
// what was in the persistent volume.
|
|
417
|
+
try {
|
|
418
|
+
applyRuntimeConfig(containerName);
|
|
419
|
+
|
|
420
|
+
// Also guarantee models.providers.rev4a (host-dependent, not in base image)
|
|
421
|
+
const providerConfig = buildRev4aProviderConfig();
|
|
422
|
+
const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
|
|
423
|
+
execSync(
|
|
424
|
+
`docker exec -i ${containerName} sh -c 'openclaw config patch --stdin'`,
|
|
425
|
+
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
execSync(`docker exec ${containerName} openclaw gateway restart`, { timeout: 15000 });
|
|
430
|
+
} catch { /* restart is best-effort */ }
|
|
431
|
+
} catch (e: unknown) {
|
|
432
|
+
// runtime config patch is best-effort; log but don't fail the create
|
|
433
|
+
console.error('[agent:create] runtime config patch failed:', (e as Error).message || String(e));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return NextResponse.json({
|
|
437
|
+
success: true,
|
|
438
|
+
containerId,
|
|
439
|
+
containerName,
|
|
440
|
+
displayName: displayName.trim(),
|
|
441
|
+
image,
|
|
442
|
+
network,
|
|
443
|
+
traefikUrl,
|
|
444
|
+
port: portStart,
|
|
445
|
+
portEnd,
|
|
446
|
+
isBlock,
|
|
447
|
+
portDisplay: isBlock ? `${portStart}-${portEnd}` : String(portStart),
|
|
448
|
+
});
|
|
449
|
+
} catch (e: unknown) {
|
|
450
|
+
const err = e as Error;
|
|
451
|
+
return NextResponse.json(
|
|
452
|
+
{ success: false, error: err.message || 'Unknown error' },
|
|
453
|
+
{ status: 500 },
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function getAvailableTemplates(): string[] {
|
|
459
|
+
try {
|
|
460
|
+
return fs.readdirSync(TEMPLATES_DIR).filter((f) => {
|
|
461
|
+
const p = path.join(TEMPLATES_DIR, f);
|
|
462
|
+
return f !== 'base-image' && f !== 'README.md' && fs.statSync(p).isDirectory();
|
|
463
|
+
});
|
|
464
|
+
} catch {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isValidModel(modelId: string): boolean {
|
|
470
|
+
try {
|
|
471
|
+
const config = loadModelsConfig();
|
|
472
|
+
return config.some((m) => m.id === modelId);
|
|
473
|
+
} catch {
|
|
474
|
+
return true; // skip on errors
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function getNetwork(): string {
|
|
479
|
+
try {
|
|
480
|
+
const networks = execSync(
|
|
481
|
+
`docker network ls --format '{{.Name}}' | grep -E '^openclaw.*'`,
|
|
482
|
+
{ encoding: 'utf-8', timeout: 3000 },
|
|
483
|
+
).trim().split('\n').filter(Boolean);
|
|
484
|
+
return networks[0] || DEFAULT_NETWORK;
|
|
485
|
+
} catch {
|
|
486
|
+
return DEFAULT_NETWORK;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type NextRequest } from "next/server";
|
|
2
|
+
/**
|
|
3
|
+
* POST /api/agents/download-image
|
|
4
|
+
*
|
|
5
|
+
* Triggers a fresh download (pull) of the openclaw-agent-base image.
|
|
6
|
+
* Returns 202 Accepted immediately — the operation runs in the background.
|
|
7
|
+
* Poll GET /api/agents/download-progress and
|
|
8
|
+
* GET /api/agents/image-status for completion.
|
|
9
|
+
*
|
|
10
|
+
* Requires auth. Returns 409 if a download is already running.
|
|
11
|
+
*/
|
|
12
|
+
import { NextResponse } from 'next/server';
|
|
13
|
+
import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
14
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
15
|
+
|
|
16
|
+
export const dynamic = 'force-dynamic';
|
|
17
|
+
|
|
18
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
19
|
+
if (getIsDownloading()) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: 'A download is already in progress' },
|
|
22
|
+
{ status: 409 },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Start download in background — fire and forget
|
|
27
|
+
downloadAgentImage({ force: true }).catch(() => {
|
|
28
|
+
// Errors are logged to the download log file
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return NextResponse.json({ started: true }, { status: 202 });
|
|
32
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { type NextRequest } from "next/server";
|
|
2
|
+
/**
|
|
3
|
+
* GET /api/agents/image-status
|
|
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).
|
|
8
|
+
*/
|
|
9
|
+
import { NextResponse } from 'next/server';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import { getIsDownloading } from '@/lib/buildAgentImage';
|
|
12
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
13
|
+
|
|
14
|
+
export const dynamic = 'force-dynamic';
|
|
15
|
+
|
|
16
|
+
const REGISTRY_IMAGE = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
|
|
17
|
+
const LOCAL_IMAGE = 'openclaw-agent-base:latest';
|
|
18
|
+
|
|
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
|
+
}
|
|
30
|
+
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
function getLocalImageId(): string | null {
|
|
45
|
+
if (!dockerOk()) return null;
|
|
46
|
+
try {
|
|
47
|
+
return execSync(
|
|
48
|
+
`docker image inspect ${LOCAL_IMAGE} --format "{{.ID}}" 2>/dev/null`,
|
|
49
|
+
{ encoding: 'utf-8', timeout: 3000 },
|
|
50
|
+
).trim() || null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fetch the remote manifest digest from ghcr.io (public, no auth). */
|
|
57
|
+
function getRemoteDigest(): string | null {
|
|
58
|
+
if (!dockerOk()) return null;
|
|
59
|
+
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;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
74
|
+
const exists = localImageExists();
|
|
75
|
+
const localId = getLocalImageId();
|
|
76
|
+
const downloading = getIsDownloading();
|
|
77
|
+
|
|
78
|
+
if (!exists) {
|
|
79
|
+
return NextResponse.json({
|
|
80
|
+
exists: false,
|
|
81
|
+
needsUpdate: true,
|
|
82
|
+
downloading,
|
|
83
|
+
localId: null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
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();
|
|
91
|
+
|
|
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
|
+
);
|
|
106
|
+
|
|
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
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return NextResponse.json({
|
|
121
|
+
exists: true,
|
|
122
|
+
needsUpdate,
|
|
123
|
+
downloading,
|
|
124
|
+
localId,
|
|
125
|
+
});
|
|
126
|
+
}
|