@flame0510/project-aether 1.2.0 → 1.4.0

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 (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. package/app/api/gateway/route.ts +0 -191
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Cold backup of one agent's volume (lib/cold-backup.ts).
3
+ *
4
+ * POST start: the agent is stopped, archived, verified and started again if it
5
+ * was running. 202 `{ started: true, file }`; the work continues in a helper container.
6
+ * GET `{ backup }`: the running or last job, with progress; null when none.
7
+ * DELETE cancel the running job: the partial archive is removed and the agent
8
+ * restarted if it was running.
9
+ */
10
+ import { NextResponse, type NextRequest } from 'next/server';
11
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
12
+ import { isValidAgentId } from '@/lib/container';
13
+ import { ColdBackupBusyError, cancelColdBackup, coldBackupStatus, startColdBackup } from '@/lib/cold-backup';
14
+ import { agentBusyReason } from '@/lib/agent-busy';
15
+
16
+ export const dynamic = 'force-dynamic';
17
+
18
+ type Ctx = { params: Promise<{ id: string }> };
19
+
20
+ async function agentId(ctx: Ctx): Promise<string | NextResponse> {
21
+ const { id } = await ctx.params;
22
+ return isValidAgentId(id) ? id : NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
23
+ }
24
+
25
+ export async function POST(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
26
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
27
+ const id = await agentId(ctx);
28
+ if (id instanceof NextResponse) return id;
29
+ // One source for every long operation: an update, recreate, restore, edit or backup.
30
+ const busy = await agentBusyReason(id);
31
+ if (busy) return NextResponse.json({ error: busy }, { status: 409 });
32
+ try {
33
+ const { file } = await startColdBackup(id, { kind: 'manual' });
34
+ return NextResponse.json({ started: true, file }, { status: 202 });
35
+ } catch (e) {
36
+ const status = e instanceof ColdBackupBusyError ? 409 : /No container|No persistent volume/.test((e as Error).message) ? 404 : 500;
37
+ return NextResponse.json({ error: (e as Error).message }, { status });
38
+ }
39
+ }
40
+
41
+ export async function GET(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
42
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
43
+ const id = await agentId(ctx);
44
+ if (id instanceof NextResponse) return id;
45
+ return NextResponse.json({ backup: await coldBackupStatus(id) }, { headers: { 'Cache-Control': 'no-store' } });
46
+ }
47
+
48
+ export async function DELETE(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
49
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
50
+ const id = await agentId(ctx);
51
+ if (id instanceof NextResponse) return id;
52
+ const cancelled = await cancelColdBackup(id);
53
+ return cancelled
54
+ ? NextResponse.json({ cancelled: true })
55
+ : NextResponse.json({ error: 'No backup of this agent is running' }, { status: 409 });
56
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Browser access to one agent's Control UI.
3
+ *
4
+ * GET pending approvals and approved browsers
5
+ * POST { action: "approve" | "reject", requestId }
6
+ * PATCH { deviceId, name } rename an approved browser
7
+ * DELETE ?deviceId=<id> revoke: the browser needs approval again
8
+ *
9
+ * All operations run the OpenClaw CLI inside the container asynchronously; see
10
+ * `lib/agent-devices.ts`.
11
+ */
12
+ import { NextResponse, type NextRequest } from 'next/server';
13
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
14
+ import {
15
+ approveBrowser,
16
+ cleanDeviceName,
17
+ findAgentContainer,
18
+ isDeviceId,
19
+ isRequestId,
20
+ listBrowserAccess,
21
+ rejectBrowser,
22
+ removeBrowser,
23
+ renameBrowser,
24
+ type AgentContainer,
25
+ } from '@/lib/agent-devices';
26
+
27
+ export const dynamic = 'force-dynamic';
28
+
29
+ type Ctx = { params: Promise<{ id: string }> };
30
+
31
+ /** The agent's container, or the response that explains why there is none to act on. */
32
+ async function resolveAgent(id: string): Promise<AgentContainer | NextResponse> {
33
+ let agent: AgentContainer | null;
34
+ try {
35
+ agent = await findAgentContainer(id);
36
+ } catch (e) {
37
+ return NextResponse.json({ error: `Docker is unreachable: ${(e as Error).message}` }, { status: 503 });
38
+ }
39
+ if (!agent) return NextResponse.json({ error: `No agent '${id}'` }, { status: 404 });
40
+ return agent;
41
+ }
42
+
43
+ const failed = (e: unknown) => NextResponse.json({ error: (e as Error).message || 'OpenClaw command failed' }, { status: 502 });
44
+
45
+ export async function GET(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
46
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
47
+ const { id } = await params;
48
+ const agent = await resolveAgent(id);
49
+ if (agent instanceof NextResponse) return agent;
50
+ if (!agent.running) return NextResponse.json({ running: false, requiresApproval: null, pending: [], approved: [] });
51
+ try {
52
+ return NextResponse.json({ running: true, ...(await listBrowserAccess(agent.name)) });
53
+ } catch (e) {
54
+ return failed(e);
55
+ }
56
+ }
57
+
58
+ async function runningAgent(id: string): Promise<AgentContainer | NextResponse> {
59
+ const agent = await resolveAgent(id);
60
+ if (agent instanceof NextResponse) return agent;
61
+ if (!agent.running) return NextResponse.json({ error: 'The agent is not running' }, { status: 409 });
62
+ return agent;
63
+ }
64
+
65
+ async function readBody(request: NextRequest): Promise<Record<string, unknown> | null> {
66
+ try {
67
+ const body = await request.json();
68
+ return body && typeof body === 'object' ? (body as Record<string, unknown>) : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export async function POST(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
75
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
76
+ const { id } = await params;
77
+ const body = await readBody(request);
78
+ if (!body || (body.action !== 'approve' && body.action !== 'reject')) {
79
+ return NextResponse.json({ error: 'action must be "approve" or "reject"' }, { status: 400 });
80
+ }
81
+ if (!isRequestId(body.requestId)) return NextResponse.json({ error: 'A valid requestId is required' }, { status: 400 });
82
+
83
+ const agent = await runningAgent(id);
84
+ if (agent instanceof NextResponse) return agent;
85
+ try {
86
+ if (body.action === 'approve') await approveBrowser(agent.name, body.requestId);
87
+ else await rejectBrowser(agent.name, body.requestId);
88
+ return NextResponse.json({ ok: true });
89
+ } catch (e) {
90
+ return failed(e);
91
+ }
92
+ }
93
+
94
+ export async function PATCH(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
95
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
96
+ const { id } = await params;
97
+ const body = await readBody(request);
98
+ if (!body || !isDeviceId(body.deviceId)) return NextResponse.json({ error: 'A valid deviceId is required' }, { status: 400 });
99
+ const name = cleanDeviceName(body.name);
100
+ if (!name) return NextResponse.json({ error: 'name must be 1-64 printable characters' }, { status: 400 });
101
+
102
+ const agent = await runningAgent(id);
103
+ if (agent instanceof NextResponse) return agent;
104
+ try {
105
+ await renameBrowser(agent.name, body.deviceId, name);
106
+ return NextResponse.json({ ok: true });
107
+ } catch (e) {
108
+ return failed(e);
109
+ }
110
+ }
111
+
112
+ export async function DELETE(request: NextRequest, { params }: Ctx): Promise<NextResponse> {
113
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
114
+ const { id } = await params;
115
+ const deviceId = request.nextUrl.searchParams.get('deviceId');
116
+ if (!isDeviceId(deviceId)) return NextResponse.json({ error: 'A valid deviceId query parameter is required' }, { status: 400 });
117
+
118
+ const agent = await runningAgent(id);
119
+ if (agent instanceof NextResponse) return agent;
120
+ try {
121
+ await removeBrowser(agent.name, deviceId);
122
+ return NextResponse.json({ ok: true });
123
+ } catch (e) {
124
+ return failed(e);
125
+ }
126
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * GET /api/agents/[id]/invite-link
3
+ *
4
+ * A Control UI link to send to someone else. It carries the gateway token, so the
5
+ * browser that opens it passes gateway auth and lands in the agent's "waiting for
6
+ * approval" list; nothing opens until an operator approves it.
7
+ *
8
+ * Refused on agents without browser approval, where the same link would open the
9
+ * Control UI directly. The host is the one this request reached Rev4a on, never a
10
+ * parameter.
11
+ */
12
+ import { NextResponse, type NextRequest } from 'next/server';
13
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
14
+ import { buildInviteLink, findAgentContainer, hostnameFromHostHeader, requiresBrowserApproval } from '@/lib/agent-devices';
15
+
16
+ export const dynamic = 'force-dynamic';
17
+
18
+ const noStore = { 'Cache-Control': 'no-store' };
19
+
20
+ const fail = (status: number, error: string) => NextResponse.json({ error }, { status, headers: noStore });
21
+
22
+ const isLoopback = (hostname: string) =>
23
+ hostname === 'localhost' || hostname.startsWith('127.') || hostname === '[::1]';
24
+
25
+ export async function GET(
26
+ request: NextRequest,
27
+ { params }: { params: Promise<{ id: string }> },
28
+ ): Promise<NextResponse> {
29
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
30
+ const { id } = await params;
31
+ const hostname = hostnameFromHostHeader(request.headers.get('host'));
32
+ if (!hostname) return fail(400, 'Could not tell which address Rev4a is being reached on');
33
+
34
+ let agent;
35
+ try {
36
+ agent = await findAgentContainer(id);
37
+ } catch (e) {
38
+ return fail(503, `Docker is unreachable: ${(e as Error).message}`);
39
+ }
40
+ if (!agent) return fail(404, `No agent '${id}'`);
41
+ if (!agent.running || !agent.controlPort) return fail(409, 'The agent is not running or publishes no Control UI port');
42
+
43
+ const requiresApproval = await requiresBrowserApproval(agent.name);
44
+ if (requiresApproval === null) return fail(502, 'Could not tell whether this agent requires browser approval');
45
+ if (!requiresApproval) return fail(409, 'This agent does not require browser approval: anyone with the link would get in');
46
+
47
+ try {
48
+ const url = await buildInviteLink(agent.name, hostname, agent.controlPort);
49
+ return NextResponse.json({ url, loopbackHost: isLoopback(hostname) }, { headers: noStore });
50
+ } catch (e) {
51
+ return fail(502, (e as Error).message);
52
+ }
53
+ }
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
2
2
  import { execSync } from 'child_process';
3
3
  import { resolveAgentedContainer, isValidAgentId, isLifecycleCoolingDown, markLifecycleCooldown } from '@/lib/container';
4
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+ import { agentBusyReason } from '@/lib/agent-busy';
5
6
 
6
7
  export const dynamic = 'force-dynamic';
7
8
 
@@ -23,6 +24,8 @@ export async function POST(
23
24
  const { id } = await params;
24
25
  if (!id) return NextResponse.json({ error: 'Agent ID required' }, { status: 400 });
25
26
  if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
27
+ const busy = await agentBusyReason(id);
28
+ if (busy) return NextResponse.json({ error: busy }, { status: 409 });
26
29
 
27
30
  const containerName = resolveAgentedContainer(id);
28
31
  if (!containerName) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * GET /api/agents/[id]/model
3
+ *
4
+ * The model configuration of one agent: primary, fallbacks, and whether the
5
+ * primary is actually resolvable from the catalogue the agent was given.
6
+ *
7
+ * This replaced `GET /api/gateway`, which reported the same two fields but walked
8
+ * every container to do it — on a seven-agent host, seven `docker exec` calls at
9
+ * ~90ms each, paid to open one agent's detail panel. This reads one container.
10
+ *
11
+ * Writes still go to `PUT /api/gateway/agent`, which already handles the
12
+ * `rev4a/` prefixing and fallback de-duplication.
13
+ */
14
+ import { NextResponse } from 'next/server';
15
+ import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
16
+ import { dockerExecNoFail } from '@/lib/docker-exec';
17
+ import { catalogueStatus } from '@/lib/model-catalogue';
18
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
19
+
20
+ export const dynamic = 'force-dynamic';
21
+
22
+ interface ModelRef {
23
+ primary: string | null;
24
+ fallbacks: string[];
25
+ }
26
+
27
+ function readModelRef(config: Record<string, unknown>): ModelRef {
28
+ const agents = (config.agents ?? {}) as Record<string, unknown>;
29
+ const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
30
+ const model = defaults.model;
31
+
32
+ // `model` accepts either a bare string or { primary, fallbacks }.
33
+ if (typeof model === 'string') return { primary: model, fallbacks: [] };
34
+ if (model && typeof model === 'object') {
35
+ const m = model as Record<string, unknown>;
36
+ return {
37
+ primary: typeof m.primary === 'string' ? m.primary : null,
38
+ fallbacks: Array.isArray(m.fallbacks) ? m.fallbacks.filter((f): f is string => typeof f === 'string') : [],
39
+ };
40
+ }
41
+ return { primary: null, fallbacks: [] };
42
+ }
43
+
44
+ /** Model ids the agent's own catalogue offers, as written by gateway/sync. */
45
+ function readCatalogueIds(config: Record<string, unknown>): string[] {
46
+ const models = (config.models ?? {}) as Record<string, unknown>;
47
+ const providers = (models.providers ?? {}) as Record<string, unknown>;
48
+ const rev4a = (providers.rev4a ?? {}) as Record<string, unknown>;
49
+ const list = Array.isArray(rev4a.models) ? rev4a.models : [];
50
+ return list
51
+ .map((m) => (m && typeof m === 'object' ? (m as Record<string, unknown>).id : null))
52
+ .filter((id): id is string => typeof id === 'string');
53
+ }
54
+
55
+ export async function GET(
56
+ request: Request,
57
+ { params }: { params: Promise<{ id: string }> },
58
+ ): Promise<NextResponse> {
59
+ const denied = await requireAuthJWT(request);
60
+ if (denied) return denied as any;
61
+
62
+ const { id } = await params;
63
+ if (!id || !isValidAgentId(id)) {
64
+ return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
65
+ }
66
+
67
+ const container = resolveAgentedContainer(id);
68
+ if (!container) {
69
+ return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
70
+ }
71
+
72
+ const raw = await dockerExecNoFail(container, ['cat', '/root/.openclaw/openclaw.json'], { timeoutMs: 8000 });
73
+ if (!raw) {
74
+ // A stopped container, or a read that timed out. `docker exec` exits 0 on
75
+ // the SIGTERM sent at the deadline, so an empty body is the only signal
76
+ // either way — report unknown rather than inventing an empty config.
77
+ return NextResponse.json({ agentId: id, reachable: false, primary: null, fallbacks: [], resolvable: null });
78
+ }
79
+
80
+ let config: Record<string, unknown>;
81
+ try {
82
+ config = JSON.parse(raw);
83
+ } catch {
84
+ return NextResponse.json({ error: 'Container config is not valid JSON' }, { status: 502 });
85
+ }
86
+
87
+ const { primary, fallbacks } = readModelRef(config);
88
+ const catalogue = readCatalogueIds(config);
89
+
90
+ // The primary is stored prefixed (`rev4a/<id>`); the catalogue stores bare ids.
91
+ const bare = primary ? primary.replace(/^rev4a\//, '') : null;
92
+
93
+ return NextResponse.json({
94
+ agentId: id,
95
+ reachable: true,
96
+ primary,
97
+ fallbacks,
98
+ /**
99
+ * False means the agent is configured for a model its own catalogue does not
100
+ * carry — it will fail on its next turn, and with no fallbacks it fails hard.
101
+ * This happens when a model is disabled in the Gateway while some agent still
102
+ * has it as primary.
103
+ */
104
+ resolvable: bare ? catalogue.includes(bare) : null,
105
+ catalogueSize: catalogue.length,
106
+ /**
107
+ * Why the primary would fail regardless of this container: `offered`,
108
+ * `disabled` (unchecked, or no provider key) or `missing` (no longer in the
109
+ * catalogue). Lets the panel name the right cause instead of guessing it.
110
+ */
111
+ primaryStatus: bare ? catalogueStatus(bare) : null,
112
+ });
113
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GET /api/agents/[id]/open-control-ui
3
+ *
4
+ * What the "Open" buttons navigate to, in a new tab. Redirects to the agent's Control
5
+ * UI: through a one-time link that pairs the browser with no approval on OpenClaw 9.x,
6
+ * or through the plain token link on 2026.7.x or when no one-time link can be issued.
7
+ *
8
+ * The target host is the one this request reached Rev4a on (the `Host` header), with
9
+ * the agent's published port. It is never a parameter: a page elsewhere that gets a
10
+ * signed-in browser to open this URL can at most open the Control UI in that same
11
+ * browser, never send the link to another host.
12
+ *
13
+ * A navigation rather than a fetch on purpose: the button opens this URL inside the
14
+ * click, so nothing is left to assign to a blank tab after an `await`, and there is
15
+ * no popup for the browser to block. See `lib/agent-devices.ts`.
16
+ */
17
+ import { NextResponse, type NextRequest } from 'next/server';
18
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
19
+ import { findAgentContainer, hostnameFromHostHeader, resolveControlUiUrl } from '@/lib/agent-devices';
20
+
21
+ export const dynamic = 'force-dynamic';
22
+
23
+ /** A plain-text page: this route is opened in a browser tab, not read by code. */
24
+ function page(status: number, text: string): NextResponse {
25
+ return new NextResponse(`${text}\n`, {
26
+ status,
27
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
28
+ });
29
+ }
30
+
31
+ export async function GET(
32
+ request: NextRequest,
33
+ { params }: { params: Promise<{ id: string }> },
34
+ ): Promise<NextResponse> {
35
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
36
+ const { id } = await params;
37
+ const host = hostnameFromHostHeader(request.headers.get('host'));
38
+ if (!host) return page(400, 'Could not tell which address Rev4a is being reached on.');
39
+
40
+ let agent;
41
+ try {
42
+ agent = await findAgentContainer(id);
43
+ } catch (e) {
44
+ return page(503, `Docker is unreachable: ${(e as Error).message}`);
45
+ }
46
+ if (!agent) return page(404, `No agent '${id}'.`);
47
+ if (!agent.running || !agent.controlPort) return page(409, 'The agent is not running or publishes no Control UI port.');
48
+
49
+ try {
50
+ const { url } = await resolveControlUiUrl(agent.name, host, agent.controlPort);
51
+ return new NextResponse(null, {
52
+ status: 302,
53
+ headers: { Location: url, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' },
54
+ });
55
+ } catch (e) {
56
+ return page(502, `Could not open the Control UI: ${(e as Error).message}.`);
57
+ }
58
+ }
@@ -1,199 +1,45 @@
1
- import { NextResponse } from 'next/server';
2
- import { execSync } from 'child_process';
3
- import * as fs from 'fs';
4
- import * as path from 'path';
5
- import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
6
- import { buildRev4aProviderConfig } from '@/app/api/gateway/sync';
7
- import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
1
+ /**
2
+ * Recreate one agent's container on the image it already runs (lib/agent-recreate.ts).
3
+ *
4
+ * POST start: a cold backup is taken (the agent stops for it), then the container
5
+ * is rebuilt and the gateway waited for. 202 once the job has started; the
6
+ * steps continue in the background and are recorded in `agent_recreates`.
7
+ * GET `{ recreate }`: the running or last job, with backup progress; null when none.
8
+ *
9
+ * While it runs, anything else that would touch the agent answers 409 (lib/agent-busy.ts).
10
+ */
11
+ import { NextResponse, type NextRequest } from 'next/server';
8
12
  import { requireAuthJWT } from '@/lib/rev4a-auth';
13
+ import { isValidAgentId } from '@/lib/container';
14
+ import { agentRecreateView, RecreateRefusedError, startAgentRecreate } from '@/lib/agent-recreate';
9
15
 
10
16
  export const dynamic = 'force-dynamic';
11
- export const maxDuration = 180; // docker run (60s) + health check (60s) + config patch (60s)
12
17
 
13
- const BACKUP_VOLUME = 'rev4a-backups';
14
-
15
- /** Timestamp YYYY-MM-DD_HHmmssSSS (ms precision avoids same-second collisions). */
16
- function ts(): string {
17
- const d = new Date();
18
- const pad = (n: number) => String(n).padStart(2, '0');
19
- const ms = String(d.getMilliseconds()).padStart(3, '0');
20
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${ms}`;
21
- }
18
+ type Ctx = { params: Promise<{ id: string }> };
22
19
 
23
- /**
24
- * Wait for the container gateway to respond on port 3000.
25
- */
26
- function waitForContainerReady(name: string, timeoutMs = 60000): boolean {
27
- const start = Date.now();
28
- while (Date.now() - start < timeoutMs) {
29
- try {
30
- execSync(
31
- `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'`,
32
- { timeout: 5000 },
33
- );
34
- return true;
35
- } catch { /* not ready */ }
36
- execSync('sleep 2', { timeout: 3000 });
37
- }
38
- return false;
20
+ async function agentId(ctx: Ctx): Promise<string | NextResponse> {
21
+ const { id } = await ctx.params;
22
+ return isValidAgentId(id) ? id : NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
39
23
  }
40
24
 
41
- /**
42
- * POST /api/agents/[id]/recreate
43
- * Stop -> backup -> rm -> run with same params + shared volumes.
44
- * Workspace data and state are preserved in the persistent volume.
45
- * Shared volumes (skills, repos) are always mounted.
46
- * After the container is up, guarantee extraDirs + provider config.
47
- */
48
- export async function POST(request: Request,
49
- { params }: { params: Promise<{ id: string }> },
50
- ): Promise<NextResponse> {
51
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
25
+ export async function POST(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
26
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
27
+ const id = await agentId(ctx);
28
+ if (id instanceof NextResponse) return id;
52
29
  try {
53
- const { id } = await params;
54
- if (!id) return NextResponse.json({ error: 'Agent name required' }, { status: 400 });
55
- if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
56
-
57
- // Resolve the actual container name via AGENT_ID label
58
- const containerName = resolveAgentedContainer(id);
59
- if (!containerName) {
60
- return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
61
- }
62
-
63
- // Inspect the existing container to capture its parameters
64
- let inspect: string;
65
- try {
66
- inspect = execSync(
67
- `docker inspect ${containerName}`,
68
- { timeout: 10000, encoding: 'utf-8' },
69
- ).trim();
70
- } catch {
71
- return NextResponse.json({ error: `Container '${containerName}' not found` }, { status: 404 });
72
- }
73
-
74
- const info = JSON.parse(inspect);
75
- const [container] = info;
76
-
77
- const image = container.Config?.Image || 'openclaw-agent-base:latest';
78
- const env: string[] = container.Config?.Env || [];
79
-
80
- // Filter and reformat env vars (skip system vars, keep AGENT_*, MODEL_*, OPENCLAW_*, TZ)
81
- const keepPrefixes = ['AGENT_', 'MODEL_', 'OPENCLAW_', 'TZ='];
82
- const envOpts = env
83
- .filter((e: string) => keepPrefixes.some((p) => e.startsWith(p)))
84
- .map((e: string) => `-e ${shellQuote(e)}`)
85
- .join(' ');
86
-
87
- // Extract labels (keep AGENT_ID, traefik.*, description, maintainer)
88
- const labels = container.Config?.Labels || {};
89
- const keepLabelKeys = ['AGENT_ID', 'traefik.', 'description', 'maintainer'];
90
- const labelOpts = Object.entries(labels)
91
- .filter(([k]) => keepLabelKeys.some((p) => k === p || k.startsWith(p)))
92
- .map(([k, v]) => `-l ${shellQuote(`${k}=${v}`)}`)
93
- .join(' ');
94
-
95
- // Determine network (must exist on the container)
96
- const networks = container.NetworkSettings?.Networks || {};
97
- const network = Object.keys(networks)[0];
98
- if (!network) {
99
- return NextResponse.json({ error: 'Cannot determine network — container may have no networks attached' }, { status: 500 });
100
- }
101
-
102
- // Determine if there was a custom port mapping
103
- const portMappings = (container.HostConfig?.PortBindings || {}) as Record<string, { HostPort: string }[]>;
104
- const portOpts = Object.entries(portMappings)
105
- .map(([containerPort, bindings]) => {
106
- const hostPort = bindings[0]?.HostPort;
107
- if (!hostPort) return '';
108
- const proto = containerPort.includes('/udp') ? '/udp' : '';
109
- return `-p ${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`;
110
- })
111
- .filter(Boolean)
112
- .join(' ');
113
-
114
- const volumeName = `agent-${id}-data`;
115
-
116
- // Auto-backup the volume BEFORE destroying the container
117
- const backupFile = `agent-${id}-prerecreate-${ts()}.tar.gz`;
118
- try {
119
- execSync(
120
- `docker run --rm ` +
121
- `-v ${volumeName}:/source:ro ` +
122
- `-v ${BACKUP_VOLUME}:/backup ` +
123
- `alpine sh -c 'tar czf /backup/${backupFile} --exclude=.npm/_cacache -C /source . && chmod 644 /backup/${backupFile}'`,
124
- { timeout: 60000, stdio: 'pipe' },
125
- );
126
- } catch (backupErr) {
127
- return NextResponse.json(
128
- { error: `Pre-recreate backup failed, aborting: ${(backupErr as Error).message}` },
129
- { status: 500 },
130
- );
131
- }
132
-
133
- // Stop & remove the old container. If removal fails (Docker daemon
134
- // overloaded, container stuck in "removal in progress"), abort — we
135
- // can't run a new container with a conflicting name.
136
- try {
137
- execSync(`docker rm -f ${containerName}`, { timeout: 15000 });
138
- } catch (rmErr) {
139
- return NextResponse.json(
140
- { error: `Failed to remove old container '${containerName}': ${(rmErr as Error).message}. The original container may still be running.` },
141
- { status: 500 },
142
- );
143
- }
144
-
145
- // Run the new container with all mounts (persistent volume + shared skills + shared repos + rev4a rules)
146
- const cmd = [
147
- `docker run -d`,
148
- `--name "${id}"`,
149
- `--network "${network}"`,
150
- `--restart unless-stopped`,
151
- `--add-host host.docker.internal:host-gateway`,
152
- `-v ${volumeName}:/root`,
153
- ...getMountFlags(),
154
- labelOpts,
155
- envOpts,
156
- portOpts,
157
- `"${image}"`,
158
- ].filter(Boolean).join(' \\\n ');
159
-
160
- execSync(cmd, { timeout: 60000, encoding: 'utf-8' });
161
-
162
- // Wait for the new container to be healthy
163
- const ready = waitForContainerReady(id);
164
- if (!ready) {
165
- return NextResponse.json(
166
- { success: false, error: 'Container started but gateway did not become healthy within 60s.' },
167
- { status: 500 },
168
- );
169
- }
170
-
171
- // Guarantee Rev4a runtime config (hooks, shared-skills, provider).
172
- // Uses config patch (deep-merge) — idempotent, preserves user customizations.
173
- try {
174
- applyRuntimeConfig(id);
175
-
176
- // Also guarantee models.providers.rev4a (host-dependent, not in base image)
177
- const providerConfig = buildRev4aProviderConfig();
178
- const providerJson = JSON.stringify({ models: { providers: { rev4a: providerConfig } } });
179
- execSync(
180
- `docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
181
- { timeout: 15000, stdio: 'pipe', input: providerJson },
182
- );
183
-
184
- // No gateway restart: `config patch` reports "Change will apply without
185
- // restarting the gateway" for a models-only patch, and the Gateway watches
186
- // openclaw.json with gateway.reload defaulting to hybrid.
187
- } catch {
188
- // config patch is best-effort on recreate
189
- }
190
-
191
- return NextResponse.json({ success: true, name: id, image, network, backup: backupFile });
192
- } catch (e: unknown) {
193
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
30
+ const rowId = await startAgentRecreate(id);
31
+ return NextResponse.json({ started: true, id: rowId }, { status: 202 });
32
+ } catch (e) {
33
+ const status = e instanceof RecreateRefusedError
34
+ ? 409
35
+ : /No container|not available locally|Cannot tell which image/.test((e as Error).message) ? 404 : 500;
36
+ return NextResponse.json({ error: (e as Error).message }, { status });
194
37
  }
195
38
  }
196
39
 
197
- function shellQuote(value: string): string {
198
- return `'${value.replace(/'/g, `'\\''`)}'`;
40
+ export async function GET(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
41
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
42
+ const id = await agentId(ctx);
43
+ if (id instanceof NextResponse) return id;
44
+ return NextResponse.json({ recreate: await agentRecreateView(id) }, { headers: { 'Cache-Control': 'no-store' } });
199
45
  }