@flame0510/project-aether 1.3.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 (76) hide show
  1. package/README.md +1 -0
  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 +4 -1
  9. package/app/agents/PageClient.tsx +629 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/api/agents/[id]/backup/route.ts +26 -69
  13. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  14. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  15. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  16. package/app/api/agents/[id]/devices/route.ts +126 -0
  17. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  18. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  19. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  20. package/app/api/agents/[id]/recreate/route.ts +33 -163
  21. package/app/api/agents/[id]/restart/route.ts +5 -0
  22. package/app/api/agents/[id]/restore/route.ts +40 -70
  23. package/app/api/agents/[id]/route.ts +38 -150
  24. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  25. package/app/api/agents/[id]/update/route.ts +50 -0
  26. package/app/api/agents/activity-summary/route.ts +67 -0
  27. package/app/api/agents/create/route.ts +32 -88
  28. package/app/api/agents/devices-summary/route.ts +37 -0
  29. package/app/api/agents/download-image/route.ts +16 -9
  30. package/app/api/agents/image-status/route.ts +31 -111
  31. package/app/api/agents/route.ts +25 -49
  32. package/app/api/agents/token/route.ts +33 -10
  33. package/app/api/assistant/route.ts +2 -2
  34. package/app/api/gateway/agent/route.ts +14 -0
  35. package/app/api/gateway/provider/balance/route.ts +5 -2
  36. package/app/api/gateway/sync.ts +97 -14
  37. package/app/api/setup/agent-image/route.ts +14 -42
  38. package/app/components/DashboardToolbar.tsx +1 -1
  39. package/app/gateway/PageClient.tsx +27 -32
  40. package/bin/rev4a.js +43 -41
  41. package/daemon.js +6 -6
  42. package/docs/ARCHITECTURE.md +95 -9
  43. package/docs/FRONTEND-ARCHITECTURE.md +8 -1
  44. package/docs/REV4A.md +54 -17
  45. package/docs/dev/API-REFERENCE.md +554 -100
  46. package/docs/dev/DATABASE.md +96 -0
  47. package/docs/dev/GATEWAY.md +21 -6
  48. package/docs/rag/DATA-FRESHNESS.md +6 -4
  49. package/docs/rag/GLOSSARY.md +12 -3
  50. package/docs/rag/REV4A-OVERVIEW.md +18 -5
  51. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -2
  52. package/instrumentation.ts +43 -0
  53. package/lib/agent-busy.ts +21 -0
  54. package/lib/agent-devices.ts +361 -0
  55. package/lib/agent-edit-state.ts +108 -0
  56. package/lib/agent-edit.ts +157 -0
  57. package/lib/agent-images.ts +375 -0
  58. package/lib/agent-ports-server.ts +27 -0
  59. package/lib/agent-ports.ts +68 -0
  60. package/lib/agent-recreate-state.ts +108 -0
  61. package/lib/agent-recreate.ts +305 -0
  62. package/lib/agent-restore-state.ts +107 -0
  63. package/lib/agent-restore.ts +135 -0
  64. package/lib/agent-setup.ts +66 -17
  65. package/lib/agent-update-state.ts +122 -0
  66. package/lib/agent-update.ts +448 -0
  67. package/lib/agent-versions.json +14 -0
  68. package/lib/agent-versions.ts +80 -0
  69. package/lib/buildAgentImage.ts +88 -290
  70. package/lib/channelManager.ts +149 -102
  71. package/lib/cold-backup.ts +354 -0
  72. package/lib/credentials/delivery.ts +3 -3
  73. package/lib/db-bootstrap.mjs +76 -0
  74. package/lib/docker-utils.ts +3 -3
  75. package/lib/provider-balance.ts +33 -12
  76. package/package.json +1 -1
@@ -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
- 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 { patchRev4aProvider } 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';
9
- import { waitForGatewayReady } from '@/lib/agent-readiness';
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
- const BACKUP_VOLUME = 'rev4a-backups';
18
+ type Ctx = { params: Promise<{ id: string }> };
15
19
 
16
- /** Timestamp YYYY-MM-DD_HHmmssSSS (ms precision avoids same-second collisions). */
17
- function ts(): string {
18
- const d = new Date();
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
- * POST /api/agents/[id]/recreate
27
- * Stop -> backup -> rm -> run with same params + shared volumes.
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 { id } = await params;
38
- if (!id) return NextResponse.json({ error: 'Agent name required' }, { status: 400 });
39
- if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
40
-
41
- // Resolve the actual container name via AGENT_ID label
42
- const containerName = resolveAgentedContainer(id);
43
- if (!containerName) {
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 shellQuote(value: string): string {
174
- 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' } });
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 });
@@ -1,81 +1,51 @@
1
- import { NextResponse } from 'next/server';
2
- import { execSync } from 'child_process';
3
- import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
4
- import { requireAuthJWT } from '@/lib/rev4a-auth';
5
-
6
- export const dynamic = 'force-dynamic';
7
-
8
- const BACKUP_VOLUME = 'rev4a-backups';
9
-
10
1
  /**
11
- * POST /api/agents/[id]/restore?file=agent-xxx-2026-07-11_2115.tar.gz
12
- * Restore an agent's persistent volume from a backup.
13
- * Stops the container, restores the volume, then restarts.
2
+ * Restore one agent's volume from a backup archive (lib/agent-restore.ts).
3
+ *
4
+ * POST ?file=… start: the container is stopped, the volume cleared and the archive
5
+ * extracted (up to 30 minutes), then the container is started again even
6
+ * when the extract failed. 202 once the job has started; the steps
7
+ * continue in the background and are recorded in `agent_restores`.
8
+ * GET `{ restore }`: the running or last job; null when none.
9
+ *
10
+ * While it runs, anything else that would touch the agent answers 409 (lib/agent-busy.ts).
14
11
  */
15
- export async function POST(request: Request,
16
- { params }: { params: Promise<{ id: string }> },
17
- ): Promise<NextResponse> {
18
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
19
- try {
20
- const { id } = await params;
21
- if (!id) return NextResponse.json({ error: 'Agent name required' }, { status: 400 });
22
- if (!isValidAgentId(id)) return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
23
-
24
- const url = new URL(request.url);
25
- const file = url.searchParams.get('file');
26
- if (!file) return NextResponse.json({ error: 'file query param required' }, { status: 400 });
27
-
28
- // Safety: only allow restoring agent-specific files, reject path traversal
29
- if (!file.startsWith(`agent-${id}-`) || !file.endsWith('.tar.gz') || file.includes('/') || file.includes('..')) {
30
- return NextResponse.json({ error: 'Invalid backup file name' }, { status: 400 });
31
- }
12
+ import { NextResponse, type NextRequest } from 'next/server';
13
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
14
+ import { isValidAgentId } from '@/lib/container';
15
+ import { agentRestoreView, RestoreRefusedError, startAgentRestore } from '@/lib/agent-restore';
32
16
 
33
- const volume = `agent-${id}-data`;
17
+ export const dynamic = 'force-dynamic';
34
18
 
35
- // Resolve the actual container name via AGENT_ID label
36
- const containerName = resolveAgentedContainer(id);
19
+ type Ctx = { params: Promise<{ id: string }> };
37
20
 
38
- // Check backup exists
39
- const check = execSync(
40
- `docker run --rm -v ${BACKUP_VOLUME}:/backup alpine sh -c 'test -f /backup/${file} && echo ok || true'`,
41
- { timeout: 10000, encoding: 'utf-8' },
42
- ).trim();
43
- if (check !== 'ok') {
44
- return NextResponse.json({ error: `Backup file '${file}' not found` }, { status: 404 });
45
- }
21
+ async function agentId(ctx: Ctx): Promise<string | NextResponse> {
22
+ const { id } = await ctx.params;
23
+ return isValidAgentId(id) ? id : NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
24
+ }
46
25
 
47
- // Stop the container if running
48
- if (containerName) {
49
- execSync(`docker stop ${containerName} 2>/dev/null; exit 0`, { timeout: 15000 });
50
- }
26
+ export async function POST(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
27
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
28
+ const id = await agentId(ctx);
29
+ if (id instanceof NextResponse) return id;
51
30
 
52
- // Clear the volume and restore from backup.
53
- // Run a temporary container with the volume, delete ALL contents (including
54
- // dotfiles/dotdirs like .openclaw, .config, .local — the .[!.]* glob is
55
- // required, otherwise the restore only merges on top of stale state), then
56
- // extract the backup. Always restart the container afterwards, even on failure.
57
- try {
58
- execSync(
59
- `docker run --rm ` +
60
- `-v ${volume}:/target ` +
61
- `-v ${BACKUP_VOLUME}:/backup ` +
62
- `alpine sh -c 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null; tar xzf /backup/${file} -C /target'`,
63
- { timeout: 30000, stdio: 'pipe' },
64
- );
65
- } finally {
66
- // Restart the container regardless of extract outcome so it never stays down.
67
- if (containerName) {
68
- try {
69
- execSync(`docker start ${containerName}`, { timeout: 15000, stdio: 'pipe' });
70
- } catch {
71
- // container start failure is surfaced separately; nothing else to do here
72
- }
73
- }
74
- }
31
+ const file = new URL(request.url).searchParams.get('file');
32
+ if (!file) return NextResponse.json({ error: 'file query param required' }, { status: 400 });
75
33
 
76
- return NextResponse.json({ success: true, container: containerName || id });
77
- } catch (e: unknown) {
78
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
34
+ try {
35
+ const rowId = await startAgentRestore(id, file);
36
+ return NextResponse.json({ started: true, id: rowId }, { status: 202 });
37
+ } catch (e) {
38
+ const message = (e as Error).message;
39
+ const status = e instanceof RestoreRefusedError
40
+ ? (/Invalid backup file name|Invalid agent id/.test(message) ? 400 : /not found/.test(message) ? 404 : 409)
41
+ : 500;
42
+ return NextResponse.json({ error: (e as Error).message }, { status });
79
43
  }
80
44
  }
81
45
 
46
+ export async function GET(request: NextRequest, ctx: Ctx): Promise<NextResponse> {
47
+ const denied = await requireAuthJWT(request); if (denied) return denied as NextResponse;
48
+ const id = await agentId(ctx);
49
+ if (id instanceof NextResponse) return id;
50
+ return NextResponse.json({ restore: agentRestoreView(id) }, { headers: { 'Cache-Control': 'no-store' } });
51
+ }