@flame0510/project-aether 1.3.0 → 1.4.1
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 +1 -0
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +4 -1
- package/app/agents/PageClient.tsx +629 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +14 -28
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -163
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -150
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +38 -92
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +2 -2
- package/app/api/gateway/agent/route.ts +14 -0
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/sync.ts +97 -14
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/api/version/route.ts +2 -1
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/gateway/PageClient.tsx +27 -32
- package/bin/rev4a.js +43 -41
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +107 -9
- package/docs/FRONTEND-ARCHITECTURE.md +8 -1
- package/docs/REV4A.md +54 -17
- package/docs/dev/API-REFERENCE.md +573 -105
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +21 -6
- package/docs/rag/DATA-FRESHNESS.md +6 -4
- package/docs/rag/GLOSSARY.md +12 -3
- package/docs/rag/REV4A-OVERVIEW.md +18 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
- package/instrumentation.ts +43 -0
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +149 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +78 -0
- package/lib/agent-ports.ts +91 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +359 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +141 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +456 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +149 -102
- package/lib/cold-backup.ts +354 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/provider-balance.ts +33 -12
- package/package.json +1 -1
|
@@ -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,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,175 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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';
|
|
9
|
-
import {
|
|
13
|
+
import { isValidAgentId } from '@/lib/container';
|
|
14
|
+
import { agentRecreateView, RecreateRefusedError, startAgentRecreate } from '@/lib/agent-recreate';
|
|
10
15
|
|
|
11
16
|
export const dynamic = 'force-dynamic';
|
|
12
|
-
export const maxDuration = 180; // docker run (60s) + startup wait (60s) + config patch (60s)
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const pad = (n: number) => String(n).padStart(2, '0');
|
|
20
|
-
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
21
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${ms}`;
|
|
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 });
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* Workspace data and state are preserved in the persistent volume.
|
|
29
|
-
* Shared volumes (skills, repos) are always mounted.
|
|
30
|
-
* After the container is up, guarantee extraDirs + provider config.
|
|
31
|
-
*/
|
|
32
|
-
export async function POST(request: Request,
|
|
33
|
-
{ params }: { params: Promise<{ id: string }> },
|
|
34
|
-
): Promise<NextResponse> {
|
|
35
|
-
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;
|
|
36
29
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Inspect the existing container to capture its parameters
|
|
48
|
-
let inspect: string;
|
|
49
|
-
try {
|
|
50
|
-
inspect = execSync(
|
|
51
|
-
`docker inspect ${containerName}`,
|
|
52
|
-
{ timeout: 10000, encoding: 'utf-8' },
|
|
53
|
-
).trim();
|
|
54
|
-
} catch {
|
|
55
|
-
return NextResponse.json({ error: `Container '${containerName}' not found` }, { status: 404 });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const info = JSON.parse(inspect);
|
|
59
|
-
const [container] = info;
|
|
60
|
-
|
|
61
|
-
const image = container.Config?.Image || 'openclaw-agent-base:latest';
|
|
62
|
-
const env: string[] = container.Config?.Env || [];
|
|
63
|
-
|
|
64
|
-
// Filter and reformat env vars (skip system vars, keep AGENT_*, MODEL_*, OPENCLAW_*, TZ)
|
|
65
|
-
const keepPrefixes = ['AGENT_', 'MODEL_', 'OPENCLAW_', 'TZ='];
|
|
66
|
-
const envOpts = env
|
|
67
|
-
.filter((e: string) => keepPrefixes.some((p) => e.startsWith(p)))
|
|
68
|
-
.map((e: string) => `-e ${shellQuote(e)}`)
|
|
69
|
-
.join(' ');
|
|
70
|
-
|
|
71
|
-
// Extract labels (keep AGENT_ID, traefik.*, description, maintainer)
|
|
72
|
-
const labels = container.Config?.Labels || {};
|
|
73
|
-
const keepLabelKeys = ['AGENT_ID', 'traefik.', 'description', 'maintainer'];
|
|
74
|
-
const labelOpts = Object.entries(labels)
|
|
75
|
-
.filter(([k]) => keepLabelKeys.some((p) => k === p || k.startsWith(p)))
|
|
76
|
-
.map(([k, v]) => `-l ${shellQuote(`${k}=${v}`)}`)
|
|
77
|
-
.join(' ');
|
|
78
|
-
|
|
79
|
-
// Determine network (must exist on the container)
|
|
80
|
-
const networks = container.NetworkSettings?.Networks || {};
|
|
81
|
-
const network = Object.keys(networks)[0];
|
|
82
|
-
if (!network) {
|
|
83
|
-
return NextResponse.json({ error: 'Cannot determine network — container may have no networks attached' }, { status: 500 });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Determine if there was a custom port mapping
|
|
87
|
-
const portMappings = (container.HostConfig?.PortBindings || {}) as Record<string, { HostPort: string }[]>;
|
|
88
|
-
const portOpts = Object.entries(portMappings)
|
|
89
|
-
.map(([containerPort, bindings]) => {
|
|
90
|
-
const hostPort = bindings[0]?.HostPort;
|
|
91
|
-
if (!hostPort) return '';
|
|
92
|
-
const proto = containerPort.includes('/udp') ? '/udp' : '';
|
|
93
|
-
return `-p ${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`;
|
|
94
|
-
})
|
|
95
|
-
.filter(Boolean)
|
|
96
|
-
.join(' ');
|
|
97
|
-
|
|
98
|
-
const volumeName = `agent-${id}-data`;
|
|
99
|
-
|
|
100
|
-
// Auto-backup the volume BEFORE destroying the container
|
|
101
|
-
const backupFile = `agent-${id}-prerecreate-${ts()}.tar.gz`;
|
|
102
|
-
try {
|
|
103
|
-
execSync(
|
|
104
|
-
`docker run --rm ` +
|
|
105
|
-
`-v ${volumeName}:/source:ro ` +
|
|
106
|
-
`-v ${BACKUP_VOLUME}:/backup ` +
|
|
107
|
-
`alpine sh -c 'tar czf /backup/${backupFile} --exclude=.npm/_cacache -C /source . && chmod 644 /backup/${backupFile}'`,
|
|
108
|
-
{ timeout: 60000, stdio: 'pipe' },
|
|
109
|
-
);
|
|
110
|
-
} catch (backupErr) {
|
|
111
|
-
return NextResponse.json(
|
|
112
|
-
{ error: `Pre-recreate backup failed, aborting: ${(backupErr as Error).message}` },
|
|
113
|
-
{ status: 500 },
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Stop & remove the old container. If removal fails (Docker daemon
|
|
118
|
-
// overloaded, container stuck in "removal in progress"), abort — we
|
|
119
|
-
// can't run a new container with a conflicting name.
|
|
120
|
-
try {
|
|
121
|
-
execSync(`docker rm -f ${containerName}`, { timeout: 15000 });
|
|
122
|
-
} catch (rmErr) {
|
|
123
|
-
return NextResponse.json(
|
|
124
|
-
{ error: `Failed to remove old container '${containerName}': ${(rmErr as Error).message}. The original container may still be running.` },
|
|
125
|
-
{ status: 500 },
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Run the new container with all mounts (persistent volume + shared skills + shared repos + rev4a rules)
|
|
130
|
-
const cmd = [
|
|
131
|
-
`docker run -d`,
|
|
132
|
-
`--name "${id}"`,
|
|
133
|
-
`--network "${network}"`,
|
|
134
|
-
`--restart unless-stopped`,
|
|
135
|
-
`--add-host host.docker.internal:host-gateway`,
|
|
136
|
-
`-v ${volumeName}:/root`,
|
|
137
|
-
...getMountFlags(),
|
|
138
|
-
labelOpts,
|
|
139
|
-
envOpts,
|
|
140
|
-
portOpts,
|
|
141
|
-
`"${image}"`,
|
|
142
|
-
].filter(Boolean).join(' \\\n ');
|
|
143
|
-
|
|
144
|
-
execSync(cmd, { timeout: 60000, encoding: 'utf-8' });
|
|
145
|
-
|
|
146
|
-
// Wait for the new container to finish starting — /startupz, not /health
|
|
147
|
-
const ready = await waitForGatewayReady(id);
|
|
148
|
-
if (!ready) {
|
|
149
|
-
return NextResponse.json(
|
|
150
|
-
{ success: false, error: 'Container started but its gateway did not finish starting within 60s.' },
|
|
151
|
-
{ status: 500 },
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Guarantee Rev4a runtime config (hooks, shared-skills, provider).
|
|
156
|
-
// Uses config patch (deep-merge) — idempotent, preserves user customizations.
|
|
157
|
-
try {
|
|
158
|
-
applyRuntimeConfig(id);
|
|
159
|
-
|
|
160
|
-
// Also guarantee models.providers.rev4a (host-dependent, not in base image)
|
|
161
|
-
patchRev4aProvider(id);
|
|
162
|
-
} catch (e) {
|
|
163
|
-
// best-effort on recreate, but a skipped patch must show up in the log
|
|
164
|
-
console.error('[agent:recreate] runtime config patch failed:', (e as Error).message);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return NextResponse.json({ success: true, name: id, image, network, backup: backupFile });
|
|
168
|
-
} catch (e: unknown) {
|
|
169
|
-
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 });
|
|
170
37
|
}
|
|
171
38
|
}
|
|
172
39
|
|
|
173
|
-
function
|
|
174
|
-
|
|
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' } });
|
|
175
45
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
3
|
import { resolveAgentedContainer, isValidAgentId } 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,10 @@ export async function POST(request: Request,
|
|
|
23
24
|
return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// A restart during an update, recreate, restore, edit or backup would cut it in half.
|
|
28
|
+
const busy = await agentBusyReason(id);
|
|
29
|
+
if (busy) return NextResponse.json({ error: busy }, { status: 409 });
|
|
30
|
+
|
|
26
31
|
execSync(`docker restart ${containerName}`, { timeout: 30000 });
|
|
27
32
|
|
|
28
33
|
return NextResponse.json({ success: true });
|