@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.
Files changed (290) hide show
  1. package/README.md +150 -0
  2. package/agent-templates/README.md +37 -0
  3. package/agent-templates/argus/AGENTS.md +52 -0
  4. package/agent-templates/argus/IDENTITY.md +7 -0
  5. package/agent-templates/argus/MEMORY.md +9 -0
  6. package/agent-templates/argus/SOUL.md +24 -0
  7. package/agent-templates/argus/TOOLS.md +8 -0
  8. package/agent-templates/atlas/AGENTS.md +47 -0
  9. package/agent-templates/atlas/HEARTBEAT.md +10 -0
  10. package/agent-templates/atlas/IDENTITY.md +6 -0
  11. package/agent-templates/atlas/MEMORY.md +9 -0
  12. package/agent-templates/atlas/SOUL.md +21 -0
  13. package/agent-templates/atlas/TOOLS.md +4 -0
  14. package/agent-templates/base-image/.trigger +0 -0
  15. package/agent-templates/base-image/Dockerfile +90 -0
  16. package/agent-templates/base-image/entrypoint.sh +37 -0
  17. package/agent-templates/custom/AGENTS.md +29 -0
  18. package/agent-templates/custom/BOOTSTRAP.md +56 -0
  19. package/agent-templates/custom/MEMORY.md +8 -0
  20. package/agent-templates/prometheus/AGENTS.md +37 -0
  21. package/agent-templates/prometheus/IDENTITY.md +6 -0
  22. package/agent-templates/prometheus/MEMORY.md +9 -0
  23. package/agent-templates/prometheus/SOUL.md +21 -0
  24. package/agent-templates/prometheus/TOOLS.md +4 -0
  25. package/app/agents/ChannelManager.tsx +465 -0
  26. package/app/agents/ImageDownloadBanner.tsx +159 -0
  27. package/app/agents/PageClient.tsx +1285 -0
  28. package/app/agents/create/PageClient.tsx +437 -0
  29. package/app/agents/create/page.tsx +126 -0
  30. package/app/agents/loading.tsx +14 -0
  31. package/app/agents/page.tsx +5 -0
  32. package/app/api/agents/[id]/backup/route.ts +169 -0
  33. package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
  34. package/app/api/agents/[id]/channels/route.ts +14 -0
  35. package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
  36. package/app/api/agents/[id]/lifecycle/route.ts +94 -0
  37. package/app/api/agents/[id]/recreate/route.ts +199 -0
  38. package/app/api/agents/[id]/restart/route.ts +33 -0
  39. package/app/api/agents/[id]/restore/route.ts +81 -0
  40. package/app/api/agents/[id]/route.ts +280 -0
  41. package/app/api/agents/channels-summary/route.ts +39 -0
  42. package/app/api/agents/config-json/route.ts +58 -0
  43. package/app/api/agents/create/route.ts +488 -0
  44. package/app/api/agents/download-image/route.ts +32 -0
  45. package/app/api/agents/image-status/route.ts +126 -0
  46. package/app/api/agents/route.ts +213 -0
  47. package/app/api/agents/token/route.ts +94 -0
  48. package/app/api/agents-active/route.ts +190 -0
  49. package/app/api/agents-config/route.ts +629 -0
  50. package/app/api/alerts/smoke/route.ts +26 -0
  51. package/app/api/aliases/add/route.ts +28 -0
  52. package/app/api/aliases/remove/route.ts +27 -0
  53. package/app/api/assistant/route.ts +204 -0
  54. package/app/api/auth/check/route.ts +38 -0
  55. package/app/api/auth/login/route.ts +85 -0
  56. package/app/api/auth/logout/route.ts +7 -0
  57. package/app/api/auth/status/route.ts +33 -0
  58. package/app/api/config/env/route.ts +132 -0
  59. package/app/api/config/restart/route.ts +26 -0
  60. package/app/api/containers/route.ts +75 -0
  61. package/app/api/cost-override/route.ts +46 -0
  62. package/app/api/costs/route.ts +84 -0
  63. package/app/api/credentials/[id]/reveal/route.ts +21 -0
  64. package/app/api/credentials/[id]/route.ts +52 -0
  65. package/app/api/credentials/[id]/sync/route.ts +58 -0
  66. package/app/api/credentials/detect/route.ts +246 -0
  67. package/app/api/credentials/route.ts +33 -0
  68. package/app/api/crons/[id]/route.ts +69 -0
  69. package/app/api/crons/route.ts +12 -0
  70. package/app/api/crons/runs/route.ts +31 -0
  71. package/app/api/debug/route.ts +11 -0
  72. package/app/api/envcheck/route.ts +18 -0
  73. package/app/api/events/route.ts +24 -0
  74. package/app/api/gateway/agent/route.ts +154 -0
  75. package/app/api/gateway/provider/balance/route.ts +38 -0
  76. package/app/api/gateway/provider/keys.ts +38 -0
  77. package/app/api/gateway/provider/oauth/route.ts +304 -0
  78. package/app/api/gateway/provider/route.ts +209 -0
  79. package/app/api/gateway/route.ts +223 -0
  80. package/app/api/gateway/sync.ts +189 -0
  81. package/app/api/lineage/route.ts +54 -0
  82. package/app/api/memory-context/route.ts +15 -0
  83. package/app/api/metrics/route.ts +35 -0
  84. package/app/api/models/route.ts +77 -0
  85. package/app/api/plugins/route.js +37 -0
  86. package/app/api/provider/upstream.ts +142 -0
  87. package/app/api/provider/v1/chat/completions/route.ts +146 -0
  88. package/app/api/provider/v1/models/route.ts +167 -0
  89. package/app/api/provider-usage/route.ts +629 -0
  90. package/app/api/session/route.ts +39 -0
  91. package/app/api/sessions/[id]/route.ts +38 -0
  92. package/app/api/sessions/route.ts +23 -0
  93. package/app/api/setup/agent-image/route.ts +102 -0
  94. package/app/api/setup/password/route.ts +111 -0
  95. package/app/api/setup/restart/route.ts +76 -0
  96. package/app/api/skills/delete/route.js +105 -0
  97. package/app/api/skills/promote/route.js +129 -0
  98. package/app/api/skills/route.js +203 -0
  99. package/app/api/skills/save/route.js +142 -0
  100. package/app/api/stats/route.ts +34 -0
  101. package/app/api/stats-since/route.ts +20 -0
  102. package/app/api/stream/route.ts +70 -0
  103. package/app/api/system-health/route.ts +233 -0
  104. package/app/api/tool-calls/route.ts +24 -0
  105. package/app/api/tools-config/route.ts +163 -0
  106. package/app/api/update-check/route.ts +127 -0
  107. package/app/api/version/route.ts +25 -0
  108. package/app/api/wizard/complete/route.ts +32 -0
  109. package/app/api/wizard/reset/route.ts +28 -0
  110. package/app/api/wizard/status/route.ts +28 -0
  111. package/app/api/workspace/route.ts +392 -0
  112. package/app/api/workspace/stream/route.ts +136 -0
  113. package/app/components/AppShell.tsx +33 -0
  114. package/app/components/AuthGuard.tsx +91 -0
  115. package/app/components/CostBreakdown.tsx +37 -0
  116. package/app/components/DashboardHeader.tsx +73 -0
  117. package/app/components/DashboardLayout.tsx +76 -0
  118. package/app/components/DashboardToolbar.tsx +61 -0
  119. package/app/components/FloatingActions.tsx +33 -0
  120. package/app/components/Icons.tsx +78 -0
  121. package/app/components/LineageGraphPage.tsx +142 -0
  122. package/app/components/LiveFeed.tsx +48 -0
  123. package/app/components/MobileBottomNav.tsx +84 -0
  124. package/app/components/ModelPickerModal.tsx +94 -0
  125. package/app/components/PasswordInput.tsx +86 -0
  126. package/app/components/PulseChat.tsx +362 -0
  127. package/app/components/Rev4aLoader.tsx +48 -0
  128. package/app/components/SessionDrawer.tsx +263 -0
  129. package/app/components/SessionTopology.tsx +289 -0
  130. package/app/components/Sidebar.tsx +294 -0
  131. package/app/components/Skeleton.tsx +133 -0
  132. package/app/components/SystemCockpit.tsx +169 -0
  133. package/app/components/VersionBanner.tsx +105 -0
  134. package/app/components/WizardButton.tsx +30 -0
  135. package/app/components/ui/Badge.tsx +30 -0
  136. package/app/components/ui/Button.tsx +59 -0
  137. package/app/components/ui/ConfirmModal.tsx +46 -0
  138. package/app/components/ui/FilterBar.tsx +33 -0
  139. package/app/components/ui/Input.tsx +25 -0
  140. package/app/components/ui/ItemList.tsx +83 -0
  141. package/app/components/ui/LoadingSpinner.tsx +30 -0
  142. package/app/components/ui/Metric.tsx +22 -0
  143. package/app/components/ui/Modal.tsx +42 -0
  144. package/app/components/ui/Page.tsx +18 -0
  145. package/app/components/ui/Pill.tsx +6 -0
  146. package/app/components/ui/PropertyList.tsx +24 -0
  147. package/app/components/ui/Select.tsx +26 -0
  148. package/app/components/ui/StatusCard.tsx +32 -0
  149. package/app/components/ui/Surface.tsx +14 -0
  150. package/app/components/ui/Tabs.tsx +55 -0
  151. package/app/components/ui/TemplateOption.tsx +59 -0
  152. package/app/components/ui/Toast.tsx +44 -0
  153. package/app/components/ui/index.ts +19 -0
  154. package/app/components/ui/tokens.ts +18 -0
  155. package/app/config/PageClient.tsx +293 -0
  156. package/app/config/loading.tsx +10 -0
  157. package/app/config/page.tsx +7 -0
  158. package/app/containers/ContainersClient.tsx +129 -0
  159. package/app/containers/loading.tsx +21 -0
  160. package/app/containers/page.tsx +5 -0
  161. package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
  162. package/app/containers/terminal/[id]/page.tsx +6 -0
  163. package/app/credentials/PageClient.tsx +598 -0
  164. package/app/credentials/loading.tsx +10 -0
  165. package/app/credentials/page.tsx +7 -0
  166. package/app/crons/PageClient.tsx +538 -0
  167. package/app/crons/loading.tsx +10 -0
  168. package/app/crons/page.tsx +5 -0
  169. package/app/design-system.ts +57 -0
  170. package/app/gateway/PageClient.tsx +1048 -0
  171. package/app/gateway/loading.tsx +24 -0
  172. package/app/gateway/page.tsx +5 -0
  173. package/app/globals.css +1474 -0
  174. package/app/layout.tsx +45 -0
  175. package/app/lib/models-context.tsx +41 -0
  176. package/app/lineage/loading.tsx +10 -0
  177. package/app/lineage/page.tsx +13 -0
  178. package/app/loading.tsx +16 -0
  179. package/app/login/page.tsx +160 -0
  180. package/app/memory/MemoryContextPageClient.tsx +196 -0
  181. package/app/memory/loading.tsx +10 -0
  182. package/app/memory/page.tsx +8 -0
  183. package/app/page.tsx +31 -0
  184. package/app/plugins/PageClient.tsx +296 -0
  185. package/app/plugins/loading.tsx +10 -0
  186. package/app/plugins/page.tsx +5 -0
  187. package/app/setup/PageClient.tsx +227 -0
  188. package/app/setup/loading.tsx +21 -0
  189. package/app/setup/page.tsx +7 -0
  190. package/app/skills/PageClient.tsx +499 -0
  191. package/app/skills/loading.tsx +10 -0
  192. package/app/skills/page.tsx +5 -0
  193. package/app/tools/ToolsPageClient.tsx +308 -0
  194. package/app/tools/loading.tsx +10 -0
  195. package/app/tools/page.tsx +62 -0
  196. package/app/tools/tools-catalog.ts +141 -0
  197. package/app/wizard/PageClient.tsx +485 -0
  198. package/app/wizard/icons.tsx +89 -0
  199. package/app/wizard/loading.tsx +24 -0
  200. package/app/wizard/page.tsx +7 -0
  201. package/app/wizard/storage.ts +33 -0
  202. package/app/wizard/useWizard.ts +101 -0
  203. package/app/workspace/WorkspaceClient.tsx +633 -0
  204. package/app/workspace/loading.tsx +36 -0
  205. package/app/workspace/page.tsx +8 -0
  206. package/bin/docker-entrypoint.sh +21 -0
  207. package/bin/postinstall.js +20 -0
  208. package/bin/rev4a.js +696 -0
  209. package/daemon.js +783 -0
  210. package/docs/ARCHITECTURE.md +557 -0
  211. package/docs/CONTAINER-TERMINAL.md +280 -0
  212. package/docs/DESIGN-SYSTEM.md +99 -0
  213. package/docs/FRONTEND-ARCHITECTURE.md +119 -0
  214. package/docs/REV4A.md +504 -0
  215. package/docs/dev/API-REFERENCE.md +1835 -0
  216. package/docs/dev/DATABASE.md +224 -0
  217. package/docs/dev/GATEWAY.md +300 -0
  218. package/docs/dev/PROVIDERS.md +229 -0
  219. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
  220. package/docs/dev/WORKSPACE.md +199 -0
  221. package/docs/rag/DATA-FRESHNESS.md +109 -0
  222. package/docs/rag/GLOSSARY.md +125 -0
  223. package/docs/rag/REV4A-OVERVIEW.md +135 -0
  224. package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
  225. package/lib/agent-setup.ts +130 -0
  226. package/lib/alerts.ts +115 -0
  227. package/lib/apiFetch.ts +7 -0
  228. package/lib/auth.ts +28 -0
  229. package/lib/billing.ts +100 -0
  230. package/lib/buildAgentImage.ts +346 -0
  231. package/lib/channelManager.ts +386 -0
  232. package/lib/container.ts +60 -0
  233. package/lib/credentials/db.ts +86 -0
  234. package/lib/credentials/delivery.ts +448 -0
  235. package/lib/credentials/detect.ts +149 -0
  236. package/lib/credentials/hash.ts +9 -0
  237. package/lib/credentials/providers.ts +110 -0
  238. package/lib/credentials/vault.ts +195 -0
  239. package/lib/db-bootstrap.d.ts +3 -0
  240. package/lib/db-bootstrap.mjs +144 -0
  241. package/lib/db.ts +25 -0
  242. package/lib/docker-utils.ts +60 -0
  243. package/lib/hooks/useDashboard.ts +264 -0
  244. package/lib/hooks/useRev4aTimezone.ts +28 -0
  245. package/lib/hooks/useScreenshot.ts +30 -0
  246. package/lib/memory-context.ts +385 -0
  247. package/lib/model-catalogue.ts +131 -0
  248. package/lib/model-pricing.ts +63 -0
  249. package/lib/openclaw-cron.ts +350 -0
  250. package/lib/patterns/ApiAdapter.ts +73 -0
  251. package/lib/patterns/ApiClient.ts +65 -0
  252. package/lib/patterns/EventBus.ts +113 -0
  253. package/lib/patterns/FilterStrategy.ts +79 -0
  254. package/lib/patterns/SessionFactory.ts +117 -0
  255. package/lib/patterns/sessionPresentation.ts +117 -0
  256. package/lib/provider-balance.ts +163 -0
  257. package/lib/requireAuth.tsx +38 -0
  258. package/lib/rev4a-auth.d.ts +3 -0
  259. package/lib/rev4a-auth.js +36 -0
  260. package/lib/rev4a-paths.ts +145 -0
  261. package/lib/timezone.ts +46 -0
  262. package/lib/types/index.ts +96 -0
  263. package/lib/utils/format.ts +82 -0
  264. package/lineage.js +56 -0
  265. package/model-pricing.json +482 -0
  266. package/models.config.json +844 -0
  267. package/next-env.d.ts +6 -0
  268. package/next.config.mjs +7 -0
  269. package/package.json +82 -0
  270. package/public/apple-touch-icon.png +0 -0
  271. package/public/favicon-64.png +0 -0
  272. package/public/favicon.svg +58 -0
  273. package/public/icon-192-maskable.png +0 -0
  274. package/public/icon-192.png +0 -0
  275. package/public/icon-512-maskable.png +0 -0
  276. package/public/icon-512.png +0 -0
  277. package/public/manifest.json +38 -0
  278. package/public/rev4a-logo.png +0 -0
  279. package/public/rev4a-logo.svg +58 -0
  280. package/rev4a-rules/AGENTS.md +37 -0
  281. package/scripts/add-auth.cjs +185 -0
  282. package/scripts/backup.sh +66 -0
  283. package/scripts/check-providers.mjs +143 -0
  284. package/scripts/docker/traefik/docker-compose.yml +32 -0
  285. package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
  286. package/scripts/docker/traefik/traefik.yml +37 -0
  287. package/scripts/remove-auth-profile.js +56 -0
  288. package/scripts/restore.sh +82 -0
  289. package/terminal-ws-server.js +139 -0
  290. 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
+ }