@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,305 @@
1
+ /**
2
+ * Recreating an agent container on a given image while keeping everything else: the
3
+ * persistent volume, the AGENT_*, MODEL_*, OPENCLAW_* and TZ environment, the AGENT_ID
4
+ * and routing labels, the published ports and the network.
5
+ *
6
+ * Shared by the Update action (another version), the recreate action (same version,
7
+ * `startAgentRecreate`) and the edit route (PATCH, synchronous, no backup). The caller
8
+ * picks the image and takes care of backups.
9
+ *
10
+ * Arguments go to `docker` as an argv array, and environment values reach it through
11
+ * its own environment (`-e KEY`), so the gateway token never sits in the process table.
12
+ */
13
+ import { dockerFetch } from '@/lib/docker-socket';
14
+ import { runDocker, resolveRecreateImage } from '@/lib/agent-images';
15
+ import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
16
+ import { isValidAgentId } from '@/lib/container';
17
+ import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
18
+ import { waitForGatewayReady } from '@/lib/agent-readiness';
19
+ import { patchRev4aProvider } from '@/app/api/gateway/sync';
20
+ import { activeRecreateAgentIds, insertRecreate, isRecreateActive, latestRecreate, markInterruptedRecreates, updateRecreateRow, type AgentRecreateRow } from '@/lib/agent-recreate-state';
21
+ import { agentBusyReason } from '@/lib/agent-busy';
22
+
23
+ const KEEP_ENV_PREFIXES = ['AGENT_', 'MODEL_', 'OPENCLAW_', 'TZ='];
24
+ const KEEP_LABEL_PREFIXES = ['AGENT_ID', 'traefik.', 'description', 'maintainer'];
25
+
26
+ /** How long the recreated gateway may take to report ready. */
27
+ const RECREATE_READY_TIMEOUT_MS = 5 * 60 * 1000;
28
+
29
+ /** A cold backup of a 13 GB volume takes ~8 minutes; anything past this is stuck. */
30
+ const COLD_BACKUP_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
31
+
32
+ export interface AgentContainerInspect {
33
+ Name?: string;
34
+ Image?: string;
35
+ Config?: { Image?: string; Env?: string[]; Labels?: Record<string, string> };
36
+ State?: { Running?: boolean; Status?: string };
37
+ HostConfig?: { NetworkMode?: string; PortBindings?: Record<string, { HostPort?: string }[] | null> };
38
+ NetworkSettings?: { Networks?: Record<string, unknown> };
39
+ }
40
+
41
+ /** The container carrying `AGENT_ID=<agentId>`, inspected; null when there is none. */
42
+ export async function inspectAgentContainer(agentId: string): Promise<AgentContainerInspect | null> {
43
+ const filters = encodeURIComponent(JSON.stringify({ label: [`AGENT_ID=${agentId}`] }));
44
+ const list = await dockerFetch<{ Id?: string }[]>('GET', `/containers/json?all=true&filters=${filters}`);
45
+ const id = Array.isArray(list) ? list[0]?.Id : undefined;
46
+ if (!id) return null;
47
+ const info = await dockerFetch<AgentContainerInspect & { Id?: string }>('GET', `/containers/${id}/json`);
48
+ return typeof info?.Id === 'string' ? info : null;
49
+ }
50
+
51
+ /**
52
+ * Remove the container and run a new one on `image` with the same parameters. The new
53
+ * container is named after the agent id, as the create route names it. `opts.env`
54
+ * overrides single environment values (the edit route renames the agent this way),
55
+ * `opts.portArgs` replaces the port bindings carried over from the inspect.
56
+ */
57
+ export async function recreateAgentContainer(
58
+ agentId: string,
59
+ container: AgentContainerInspect,
60
+ image: string,
61
+ opts: { env?: Record<string, string>; portArgs?: string[] } = {},
62
+ ): Promise<void> {
63
+ // A running container carries its attached networks; a container whose network
64
+ // endpoint was lost (or one inspected while detached) reports none, so the mode it
65
+ // was created with is the fallback. 'default' is docker's alias for the default bridge.
66
+ const network = Object.keys(container.NetworkSettings?.Networks ?? {})[0]
67
+ ?? (container.HostConfig?.NetworkMode && container.HostConfig.NetworkMode !== 'default'
68
+ ? container.HostConfig.NetworkMode
69
+ : container.HostConfig?.NetworkMode === 'default' ? 'bridge' : undefined);
70
+ if (!network) throw new Error('Cannot determine the network: the container has none attached');
71
+
72
+ const env: Record<string, string> = {};
73
+ for (const entry of container.Config?.Env ?? []) {
74
+ if (!KEEP_ENV_PREFIXES.some((p) => entry.startsWith(p))) continue;
75
+ const eq = entry.indexOf('=');
76
+ if (eq > 0) env[entry.slice(0, eq)] = entry.slice(eq + 1);
77
+ }
78
+ Object.assign(env, opts.env ?? {});
79
+
80
+ const labelArgs = Object.entries(container.Config?.Labels ?? {})
81
+ .filter(([k]) => KEEP_LABEL_PREFIXES.some((p) => k === p || k.startsWith(p)))
82
+ .flatMap(([k, v]) => ['-l', `${k}=${v}`]);
83
+
84
+ const portArgs = opts.portArgs ?? Object.entries(container.HostConfig?.PortBindings ?? {}).flatMap(([containerPort, bindings]) => {
85
+ const hostPort = bindings?.[0]?.HostPort;
86
+ if (!hostPort) return [];
87
+ const proto = containerPort.includes('/udp') ? '/udp' : '';
88
+ return ['-p', `${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`];
89
+ });
90
+
91
+ const oldName = (container.Name ?? '').replace(/^\//, '') || agentId;
92
+ await runDocker(['rm', '-f', oldName], { timeoutMs: 60_000 });
93
+
94
+ const runArgs = (img: string): string[] => [
95
+ 'run', '-d',
96
+ '--name', agentId,
97
+ '--network', network,
98
+ '--restart', 'unless-stopped',
99
+ '--add-host', 'host.docker.internal:host-gateway',
100
+ '-v', `agent-${agentId}-data:/root`,
101
+ ...getMountFlags(),
102
+ ...labelArgs,
103
+ ...portArgs,
104
+ img,
105
+ ];
106
+
107
+ try {
108
+ await runDocker(runArgs(image), { timeoutMs: 120_000, env });
109
+ } catch (first) {
110
+ // A failed `docker run` here would leave the agent down (the old container is
111
+ // already gone), so try once more. The usual cause is the image tag disappearing
112
+ // between the caller resolving it and this run (retention untags in the
113
+ // background) or a transient daemon error: re-resolving from the inspected
114
+ // container re-tags the image id when the tag is gone. A second failure propagates.
115
+ console.warn(`[agent:recreate] docker run failed for ${agentId}, retrying:`, (first as Error).message);
116
+ await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
117
+ const retryImage = await resolveRecreateImage(container).catch(() => image);
118
+ await runDocker(runArgs(retryImage), { timeoutMs: 120_000, env });
119
+ }
120
+ }
121
+
122
+ // ── The recreate action ──────────────────────────────────────────────────────
123
+
124
+ export class RecreateRefusedError extends Error {}
125
+
126
+ /** Recreate jobs running in this process, so a second start for the same agent is refused. */
127
+ const running = new Set<string>();
128
+
129
+ /**
130
+ * Start a recreate: a cold backup (the agent stops for it), then the container is
131
+ * rebuilt on the image it already runs, and the gateway is waited for. Returns once
132
+ * the job has started; the steps continue in the background and land in
133
+ * `agent_recreates`, so a reload or a Rev4a restart never loses track of it.
134
+ */
135
+ export async function startAgentRecreate(agentId: string): Promise<number> {
136
+ if (!isValidAgentId(agentId)) throw new RecreateRefusedError('Invalid agent id');
137
+ if (running.has(agentId)) throw new RecreateRefusedError('A recreate of this agent is already running');
138
+ // Claimed synchronously, before the first await: two requests arriving together must
139
+ // not both get past the guards (the second would later clear the first one's guard).
140
+ running.add(agentId);
141
+ try {
142
+ // One source for every long operation: an update, recreate, restore, edit or backup.
143
+ const busy = await agentBusyReason(agentId);
144
+ if (busy) throw new RecreateRefusedError(busy);
145
+
146
+ const container = await inspectAgentContainer(agentId);
147
+ if (!container) throw new RecreateRefusedError(`No container found with AGENT_ID '${agentId}'`);
148
+ // Refuse before any state is written when the image cannot be resolved.
149
+ const image = await resolveRecreateImage(container);
150
+
151
+ const id = insertRecreate(agentId, image);
152
+ void runRecreate(id, agentId, image).finally(() => running.delete(agentId));
153
+ return id;
154
+ } catch (e) {
155
+ running.delete(agentId);
156
+ throw e;
157
+ }
158
+ }
159
+
160
+ async function runRecreate(id: number, agentId: string, image: string): Promise<void> {
161
+ let stoppedByBackup = false;
162
+ try {
163
+ const { file } = await startColdBackup(agentId, { kind: 'prerecreate', leaveStopped: true });
164
+ stoppedByBackup = true;
165
+ updateRecreateRow(id, { backup_file: file });
166
+ const backup = await waitForColdBackup(agentId, { timeoutMs: COLD_BACKUP_WAIT_TIMEOUT_MS });
167
+ if (backup.status !== 'succeeded') throw new Error(`The pre-recreate backup failed: ${backup.error ?? 'unknown error'}`);
168
+
169
+ updateRecreateRow(id, { status: 'recreating' });
170
+ const container = await inspectAgentContainer(agentId);
171
+ if (!container) throw new Error('The agent container disappeared during the recreate');
172
+ await recreateAgentContainer(agentId, container, image);
173
+ stoppedByBackup = false;
174
+
175
+ if (!(await waitForGatewayReady(agentId, RECREATE_READY_TIMEOUT_MS))) {
176
+ throw new Error(`The gateway did not finish starting within ${RECREATE_READY_TIMEOUT_MS / 60_000} minutes`);
177
+ }
178
+ await applyRuntimeConfig(agentId);
179
+ try {
180
+ patchRev4aProvider(agentId);
181
+ } catch (e) {
182
+ console.error('[agent:recreate] provider patch failed:', (e as Error).message);
183
+ }
184
+
185
+ updateRecreateRow(id, { status: 'done' });
186
+
187
+ // Retention: keep the newest two prerecreate archives (lib/cold-backup.ts). Best-effort.
188
+ prunePrerecreateBackups(agentId)
189
+ .then((removed) => { if (removed.length) console.log(`[agent:recreate] removed old prerecreate backups: ${removed.join(', ')}`); })
190
+ .catch((err: unknown) => console.warn('[agent:recreate] backup cleanup failed:', (err as Error).message));
191
+ } catch (e) {
192
+ // A failure while the backup had stopped the old container and the recreate had
193
+ // not run yet: start the old container again, so a failed recreate never leaves
194
+ // the agent down. Nothing is left to start only when the rebuild itself failed
195
+ // after removing the container — the retry inside recreateAgentContainer already
196
+ // tried once, so the row says so and a new recreate is needed.
197
+ if (stoppedByBackup) {
198
+ const container = await inspectAgentContainer(agentId).catch(() => null);
199
+ const name = container?.Name?.replace(/^\//, '');
200
+ if (name && !container?.State?.Running) {
201
+ await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
202
+ } else if (!container) {
203
+ console.error(`[agent:recreate] ${agentId}: the container is gone after the failed rebuild; run Recreate again`);
204
+ }
205
+ }
206
+ updateRecreateRow(id, { status: 'failed', error: shortError(e) });
207
+ }
208
+ }
209
+
210
+ /** The useful part of an error for the panel: a `docker` failure carries a whole stack trace. */
211
+ function shortError(e: unknown): string {
212
+ const message = (e as Error)?.message ?? String(e);
213
+ const lines = message.split('\n').map((l) => l.trim()).filter(Boolean);
214
+ return lines[0] ?? message;
215
+ }
216
+
217
+ export interface AgentRecreateView {
218
+ id: number;
219
+ agentId: string;
220
+ status: AgentRecreateRow['status'];
221
+ image: string | null;
222
+ backupFile: string | null;
223
+ /** Cold backup progress while `backing_up`. */
224
+ backupPercent: number | null;
225
+ error: string | null;
226
+ startedAtMs: number;
227
+ finishedAtMs: number | null;
228
+ }
229
+
230
+ /** The latest recreate of an agent, with live backup progress. */
231
+ export async function agentRecreateView(agentId: string): Promise<AgentRecreateView | null> {
232
+ const row = latestRecreate(agentId);
233
+ if (!row) return null;
234
+ const backupPercent = row.status === 'backing_up' ? (await coldBackupStatus(agentId).catch(() => null))?.percent ?? null : null;
235
+ return {
236
+ id: row.id,
237
+ agentId: row.agent_id,
238
+ status: row.status,
239
+ image: row.image,
240
+ backupFile: row.backup_file,
241
+ backupPercent,
242
+ error: row.error,
243
+ startedAtMs: row.started_at,
244
+ finishedAtMs: row.finished_at,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * At startup, no recreate job can be running: rows still active were cut off by the
250
+ * restart. They become `interrupted`; an agent the backup had stopped is started
251
+ * again, best-effort — but never while its backup helper is still archiving the
252
+ * volume, or the archive would be inconsistent. Startup is not held for that: each
253
+ * agent is finished in the background.
254
+ */
255
+ export async function recoverInterruptedRecreates(): Promise<number> {
256
+ const ids = activeRecreateAgentIds();
257
+ if (ids.size === 0) return 0;
258
+ const interrupted = markInterruptedRecreates();
259
+ for (const agentId of ids) void finishInterruptedRecreate(agentId);
260
+ return interrupted;
261
+ }
262
+
263
+ async function finishInterruptedRecreate(agentId: string): Promise<void> {
264
+ // A helper that outlived the restart keeps archiving; wait for it before touching
265
+ // the agent. `waitForColdBackup` resolves immediately with the last job when none runs.
266
+ const running = await isColdBackupRunning(agentId).catch(() => false);
267
+ if (running) await waitForColdBackup(agentId).catch(() => null);
268
+ const container = await inspectAgentContainer(agentId).catch(() => null);
269
+ const name = container?.Name?.replace(/^\//, '');
270
+ if (name && !container?.State?.Running) {
271
+ await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
272
+ }
273
+ }
274
+
275
+ // ── Prerecreate retention ────────────────────────────────────────────────────
276
+
277
+ /** How many prerecreate archives to keep per agent once a recreate has committed. */
278
+ const KEEP_PRERECREATE = 2;
279
+
280
+ /**
281
+ * Delete the agent's older prerecreate archives, keeping the newest
282
+ * `KEEP_PRERECREATE`. Preupdate backups are not touched: they are the Update
283
+ * rollback point. Returns the file names removed.
284
+ */
285
+ export async function prunePrerecreateBackups(agentId: string): Promise<string[]> {
286
+ const pattern = `agent-${agentId}-prerecreate-*.tar.gz`;
287
+ const stale: string[] = [];
288
+ await runDocker(
289
+ // $PATTERN stays unquoted so the shell expands the wildcard; agent ids are
290
+ // validated, so the pattern carries no shell metacharacters beyond the glob.
291
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c',
292
+ 'cd /backup && ls -1t $PATTERN 2>/dev/null | tail -n +3'],
293
+ { timeoutMs: 60_000, env: { PATTERN: pattern }, onLine: (line) => {
294
+ const name = line.trim();
295
+ if (name.endsWith('.tar.gz')) stale.push(name);
296
+ } },
297
+ ).catch(() => {});
298
+ for (const file of stale) {
299
+ await runDocker(
300
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'rm -f "/backup/$FILE"'],
301
+ { timeoutMs: 60_000, env: { FILE: file } },
302
+ ).catch(() => {});
303
+ }
304
+ return stale;
305
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Persisted state of agent restores: the `agent_restores` table (lib/db-bootstrap.mjs).
3
+ *
4
+ * Kept apart from lib/agent-restore.ts so busy checks (lib/agent-busy.ts) can ask which
5
+ * agents are being restored without importing the restore action.
6
+ */
7
+ import { openDb } from '@/lib/db';
8
+
9
+ export type RestoreStatus = 'restoring' | 'done' | 'failed' | 'interrupted';
10
+
11
+ /** Statuses during which the agent must not be touched by anything else. */
12
+ export const ACTIVE_RESTORE_STATUSES: readonly RestoreStatus[] = ['restoring'];
13
+
14
+ export interface AgentRestoreRow {
15
+ id: number;
16
+ agent_id: string;
17
+ status: RestoreStatus;
18
+ file: string | null;
19
+ error: string | null;
20
+ started_at: number;
21
+ updated_at: number;
22
+ finished_at: number | null;
23
+ }
24
+
25
+ const FINAL: readonly RestoreStatus[] = ['done', 'failed', 'interrupted'];
26
+
27
+ export function insertRestore(agentId: string, file: string): number {
28
+ const db = openDb(false);
29
+ try {
30
+ const now = Date.now();
31
+ const info = db.prepare(
32
+ `INSERT INTO agent_restores (agent_id, status, file, started_at, updated_at)
33
+ VALUES (?, 'restoring', ?, ?, ?)`,
34
+ ).run(agentId, file, now, now);
35
+ return Number(info.lastInsertRowid);
36
+ } finally {
37
+ db.close();
38
+ }
39
+ }
40
+
41
+ type Writable = Partial<Pick<AgentRestoreRow, 'status' | 'error'>>;
42
+
43
+ export function updateRestoreRow(id: number, fields: Writable): void {
44
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
45
+ const now = Date.now();
46
+ const sets = [...entries.map(([k]) => `${k} = ?`), 'updated_at = ?'];
47
+ const values: unknown[] = [...entries.map(([, v]) => v), now];
48
+ if (fields.status && FINAL.includes(fields.status)) {
49
+ sets.push('finished_at = ?');
50
+ values.push(now);
51
+ } else if (fields.status) {
52
+ sets.push('finished_at = NULL');
53
+ }
54
+ const db = openDb(false);
55
+ try {
56
+ db.prepare(`UPDATE agent_restores SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
57
+ } finally {
58
+ db.close();
59
+ }
60
+ }
61
+
62
+ export function latestRestore(agentId: string): AgentRestoreRow | null {
63
+ const db = openDb(true);
64
+ try {
65
+ return (db.prepare('SELECT * FROM agent_restores WHERE agent_id = ? ORDER BY id DESC LIMIT 1').get(agentId) as AgentRestoreRow | undefined) ?? null;
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ export function isRestoreActive(agentId: string): boolean {
72
+ const row = latestRestore(agentId);
73
+ return !!row && ACTIVE_RESTORE_STATUSES.includes(row.status);
74
+ }
75
+
76
+ /** AGENT_IDs with a restore in an active status. */
77
+ export function activeRestoreAgentIds(): Set<string> {
78
+ const db = openDb(true);
79
+ try {
80
+ const placeholders = ACTIVE_RESTORE_STATUSES.map(() => '?').join(', ');
81
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM agent_restores WHERE status IN (${placeholders})`).all(...ACTIVE_RESTORE_STATUSES) as { agent_id: string }[];
82
+ return new Set(rows.map((r) => r.agent_id));
83
+ } finally {
84
+ db.close();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * At startup no restore job can be running: a row still active was cut off by the
90
+ * restart. It becomes `interrupted`, keeping the archive it was restoring.
91
+ */
92
+ export function markInterruptedRestores(): number {
93
+ const db = openDb(false);
94
+ try {
95
+ const placeholders = ACTIVE_RESTORE_STATUSES.map(() => '?').join(', ');
96
+ const now = Date.now();
97
+ const info = db.prepare(
98
+ `UPDATE agent_restores
99
+ SET error = COALESCE(error, 'Rev4a restarted while this step was running: ' || status),
100
+ status = 'interrupted', updated_at = ?, finished_at = ?
101
+ WHERE status IN (${placeholders})`,
102
+ ).run(now, now, ...ACTIVE_RESTORE_STATUSES);
103
+ return info.changes;
104
+ } finally {
105
+ db.close();
106
+ }
107
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Restoring an agent's volume from a backup archive.
3
+ *
4
+ * The volume is cleared and the archive extracted into it — minutes for a large
5
+ * workspace — then the container is started again even when the extract failed. The
6
+ * job is recorded in `agent_restores` and runs in the background, so a page reload or
7
+ * a Rev4a restart never loses track of it and a second restore cannot start on top of
8
+ * the first (lib/agent-busy.ts answers 409 while one runs).
9
+ */
10
+ import { isValidAgentId } from '@/lib/container';
11
+ import { runDocker } from '@/lib/agent-images';
12
+ import { BACKUP_VOLUME } from '@/lib/cold-backup';
13
+ import { inspectAgentContainer } from '@/lib/agent-recreate';
14
+ import { activeRestoreAgentIds, insertRestore, isRestoreActive, latestRestore, markInterruptedRestores, updateRestoreRow, type AgentRestoreRow } from '@/lib/agent-restore-state';
15
+ import { agentBusyReason } from '@/lib/agent-busy';
16
+
17
+ /** A 13 GB workspace takes minutes to decompress; 30 minutes leaves room and still ends. */
18
+ const EXTRACT_TIMEOUT_MS = 30 * 60 * 1000;
19
+
20
+ export class RestoreRefusedError extends Error {}
21
+
22
+ /** Archive names are `<agent>-<something>.tar.gz`; nothing that could carry shell syntax. */
23
+ function isValidBackupFile(agentId: string, file: string): boolean {
24
+ return file.startsWith(`agent-${agentId}-`) && /^[A-Za-z0-9._-]+\.tar\.gz$/.test(file);
25
+ }
26
+
27
+ /** Restore jobs running in this process, so a second start for the same agent is refused. */
28
+ const running = new Set<string>();
29
+
30
+ export async function startAgentRestore(agentId: string, file: string): Promise<number> {
31
+ if (!isValidAgentId(agentId)) throw new RestoreRefusedError('Invalid agent id');
32
+ if (!isValidBackupFile(agentId, file)) throw new RestoreRefusedError('Invalid backup file name');
33
+ if (running.has(agentId) || isRestoreActive(agentId)) throw new RestoreRefusedError('A restore of this agent is already running');
34
+ // Claimed synchronously, before the first await, so two requests cannot both pass.
35
+ running.add(agentId);
36
+ try {
37
+ // One source for every long operation: an update, recreate, restore, edit or backup.
38
+ const busy = await agentBusyReason(agentId);
39
+ if (busy) throw new RestoreRefusedError(busy);
40
+
41
+ // The archive must exist before anything is stopped or cleared. It used to be
42
+ // shape-checked only, so a file that had been pruned or deleted made the job wipe
43
+ // the volume and then fail, leaving the agent on an empty volume.
44
+ await runDocker(
45
+ ['run', '--rm', '-v', `${BACKUP_VOLUME}:/backup`, 'alpine', 'sh', '-c', 'test -f "/backup/$FILE"'],
46
+ { timeoutMs: 30_000, env: { FILE: file } },
47
+ ).catch(() => {
48
+ throw new RestoreRefusedError(`Backup file '${file}' not found`);
49
+ });
50
+
51
+ const id = insertRestore(agentId, file);
52
+ void runRestore(id, agentId, file).finally(() => running.delete(agentId));
53
+ return id;
54
+ } catch (e) {
55
+ running.delete(agentId);
56
+ throw e;
57
+ }
58
+ }
59
+
60
+ async function runRestore(id: number, agentId: string, file: string): Promise<void> {
61
+ const container = await inspectAgentContainer(agentId).catch(() => null);
62
+ const name = container?.Name?.replace(/^\//, '');
63
+ try {
64
+ if (name && container?.State?.Running) {
65
+ await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 }).catch(() => {});
66
+ }
67
+
68
+ // Clear the volume, then extract. The file name reaches the container through its
69
+ // environment and the whole command is one argv element, so it is never shell syntax.
70
+ await runDocker(
71
+ [
72
+ 'run', '--rm',
73
+ '-v', `agent-${agentId}-data:/target`,
74
+ '-v', `${BACKUP_VOLUME}:/backup`,
75
+ 'alpine', 'sh', '-c',
76
+ 'test -f "/backup/$FILE" || exit 3; rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null; tar xzf "/backup/$FILE" -C /target',
77
+ ],
78
+ { timeoutMs: EXTRACT_TIMEOUT_MS, env: { FILE: file } },
79
+ );
80
+
81
+ updateRestoreRow(id, { status: 'done' });
82
+ } catch (e) {
83
+ updateRestoreRow(id, { status: 'failed', error: (e as Error)?.message ?? String(e) });
84
+ } finally {
85
+ // The container is started again even when the extract failed, so the agent never
86
+ // stays down. A volume with no container (volume-only agent) has nothing to start.
87
+ if (name) {
88
+ await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
89
+ }
90
+ }
91
+ }
92
+
93
+ export interface AgentRestoreView {
94
+ id: number;
95
+ agentId: string;
96
+ status: AgentRestoreRow['status'];
97
+ file: string | null;
98
+ error: string | null;
99
+ startedAtMs: number;
100
+ finishedAtMs: number | null;
101
+ }
102
+
103
+ /** The latest restore of an agent, so the panel can resume after a reload. */
104
+ export function agentRestoreView(agentId: string): AgentRestoreView | null {
105
+ const row = latestRestore(agentId);
106
+ if (!row) return null;
107
+ return {
108
+ id: row.id,
109
+ agentId: row.agent_id,
110
+ status: row.status,
111
+ file: row.file,
112
+ error: row.error,
113
+ startedAtMs: row.started_at,
114
+ finishedAtMs: row.finished_at,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * At startup no restore job can be running: rows still active were cut off by the
120
+ * restart. They become `interrupted`; a container left stopped is started again,
121
+ * best-effort, so nothing stays down.
122
+ */
123
+ export async function recoverInterruptedRestores(): Promise<number> {
124
+ const ids = activeRestoreAgentIds();
125
+ if (ids.size === 0) return 0;
126
+ const interrupted = markInterruptedRestores();
127
+ for (const agentId of ids) {
128
+ const container = await inspectAgentContainer(agentId).catch(() => null);
129
+ const name = container?.Name?.replace(/^\//, '');
130
+ if (name && !container?.State?.Running) {
131
+ await runDocker(['start', name], { timeoutMs: 60_000 }).catch(() => {});
132
+ }
133
+ }
134
+ return interrupted;
135
+ }
@@ -8,9 +8,9 @@
8
8
  * one-file change.
9
9
  */
10
10
 
11
- import { execSync } from 'child_process';
12
11
  import * as fs from 'fs';
13
12
  import { SHARED_SKILLS_DIR, REV4A_RULES_DIR } from '@/lib/rev4a-paths';
13
+ import { dockerExec, dockerExecWithInput } from '@/lib/docker-exec';
14
14
 
15
15
  // ── Volumes ──────────────────────────────────────────────────────────────────
16
16
 
@@ -103,27 +103,76 @@ const GUARANTEED_CONFIG_PATCHES: object[] = [
103
103
  },
104
104
  ];
105
105
 
106
+ const CONFIG_PATCH_TIMEOUT_MS = 15_000;
107
+
108
+ /** Exactly `["*"]`: the allow-all list earlier Rev4a versions wrote. */
109
+ function isWildcardOnly(value: unknown): boolean {
110
+ return Array.isArray(value) && value.length === 1 && value[0] === '*';
111
+ }
112
+
113
+ /**
114
+ * `gateway.controlUi` with Rev4a's browser-origin policy applied.
115
+ *
116
+ * The Host-header fallback accepts a Control UI page served from the host the
117
+ * browser connected to — whatever address the agent is reached on — and refuses
118
+ * pages from any other origin. Without it, a public IP is refused; loopback and
119
+ * private hosts are accepted either way. An `allowedOrigins: ["*"]` list, which
120
+ * accepts every origin, is dropped; any other list an operator set is kept. The
121
+ * retired `dangerouslyDisableDeviceAuth` is dropped too.
122
+ */
123
+ export function withControlUiPolicy(current: unknown): Record<string, unknown> {
124
+ const next: Record<string, unknown> = {
125
+ ...(current && typeof current === 'object' ? (current as Record<string, unknown>) : {}),
126
+ };
127
+ next.dangerouslyAllowHostHeaderOriginFallback = true;
128
+ delete next.dangerouslyDisableDeviceAuth;
129
+ if (isWildcardOnly(next.allowedOrigins)) delete next.allowedOrigins;
130
+ return next;
131
+ }
132
+
133
+ async function patchConfig(containerName: string, patch: object): Promise<void> {
134
+ await dockerExecWithInput(containerName, ['openclaw', 'config', 'patch', '--stdin'], JSON.stringify(patch), {
135
+ timeoutMs: CONFIG_PATCH_TIMEOUT_MS,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Apply the origin policy to a running container through merge patches.
141
+ *
142
+ * The fallback and the removal of `["*"]` go in one patch, so the Gateway reloads
143
+ * both together. The retired `dangerouslyDisableDeviceAuth` goes in a second patch:
144
+ * if the Gateway refuses it, the policy the Control UI needs is already in place.
145
+ */
146
+ async function applyControlUiPolicy(containerName: string): Promise<void> {
147
+ const raw = await dockerExec(containerName, ['cat', '/root/.openclaw/openclaw.json'], { timeoutMs: CONFIG_PATCH_TIMEOUT_MS });
148
+ const current = (JSON.parse(raw) as { gateway?: { controlUi?: Record<string, unknown> } })?.gateway?.controlUi ?? {};
149
+
150
+ const policy: Record<string, unknown> = { dangerouslyAllowHostHeaderOriginFallback: true };
151
+ if (isWildcardOnly(current.allowedOrigins)) policy.allowedOrigins = null;
152
+ await patchConfig(containerName, { gateway: { controlUi: policy } });
153
+
154
+ if ('dangerouslyDisableDeviceAuth' in current) {
155
+ await patchConfig(containerName, { gateway: { controlUi: { dangerouslyDisableDeviceAuth: null } } });
156
+ }
157
+ }
158
+
106
159
  /**
107
- * Apply all guaranteed config patches to a running container.
160
+ * Apply all guaranteed config patches to a running container, then the origin policy.
108
161
  * Uses `openclaw config patch --stdin` (deep-merge) so existing
109
- * user customizations are never overwritten.
162
+ * user customizations are never overwritten. Best-effort: a failed patch is logged
163
+ * and the others still run.
110
164
  */
111
- export function applyRuntimeConfig(containerName: string): void {
165
+ export async function applyRuntimeConfig(containerName: string): Promise<void> {
112
166
  for (const patch of GUARANTEED_CONFIG_PATCHES) {
113
- const json = JSON.stringify(patch);
114
167
  try {
115
- execSync(
116
- `docker exec -i ${escapeShell(containerName)} sh -c 'openclaw config patch --stdin'`,
117
- { timeout: 15_000, stdio: 'pipe', input: json },
118
- );
119
- } catch {
120
- // config patch is best-effort — the image may already have it
168
+ await patchConfig(containerName, patch);
169
+ } catch (e) {
170
+ console.warn(`[agent-setup] config patch failed on ${containerName}:`, (e as Error).message);
121
171
  }
122
172
  }
123
- }
124
-
125
- // ── Helpers ──────────────────────────────────────────────────────────────────
126
-
127
- export function escapeShell(value: string): string {
128
- return `'${value.replace(/'/g, `'\\''`)}'`;
173
+ try {
174
+ await applyControlUiPolicy(containerName);
175
+ } catch (e) {
176
+ console.warn(`[agent-setup] Control UI origin policy not applied on ${containerName}:`, (e as Error).message);
177
+ }
129
178
  }