@flame0510/project-aether 1.1.12 → 1.1.14

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 (72) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +140 -34
  3. package/app/agents/create/PageClient.tsx +11 -4
  4. package/app/api/agents/[id]/recreate/route.ts +3 -3
  5. package/app/api/agents/[id]/route.ts +3 -1
  6. package/app/api/agents/channels-summary/route.ts +48 -17
  7. package/app/api/agents/create/route.ts +32 -13
  8. package/app/api/agents/download-image/route.ts +2 -0
  9. package/app/api/agents/image-status/route.ts +94 -85
  10. package/app/api/agents/route.ts +10 -53
  11. package/app/api/agents/token/route.ts +4 -1
  12. package/app/api/auth/check/route.ts +2 -23
  13. package/app/api/auth/login/route.ts +3 -35
  14. package/app/api/auth/logout/route.ts +1 -1
  15. package/app/api/auth/status/route.ts +2 -21
  16. package/app/api/config/env/route.ts +7 -3
  17. package/app/api/config/restart/route.ts +2 -0
  18. package/app/api/containers/route.ts +3 -34
  19. package/app/api/crons/route.ts +2 -0
  20. package/app/api/debug/route.ts +2 -0
  21. package/app/api/envcheck/route.ts +3 -1
  22. package/app/api/gateway/agent/route.ts +8 -6
  23. package/app/api/gateway/provider/route.ts +43 -5
  24. package/app/api/gateway/route.ts +104 -136
  25. package/app/api/gateway/sync.ts +12 -15
  26. package/app/api/models/route.ts +2 -0
  27. package/app/api/provider/upstream.ts +12 -3
  28. package/app/api/setup/agent-image/route.ts +8 -3
  29. package/app/api/setup/password/route.ts +32 -37
  30. package/app/api/setup/restart/route.ts +8 -2
  31. package/app/api/skills/delete/route.js +1 -1
  32. package/app/api/skills/promote/route.js +1 -1
  33. package/app/api/skills/route.js +89 -97
  34. package/app/api/skills/save/route.js +18 -5
  35. package/app/api/update-check/route.ts +4 -23
  36. package/app/api/wizard/complete/route.ts +7 -3
  37. package/app/api/wizard/reset/route.ts +8 -3
  38. package/app/api/wizard/status/route.ts +8 -5
  39. package/app/components/AuthGuard.tsx +29 -8
  40. package/app/components/Skeleton.tsx +71 -0
  41. package/app/components/ui/ModalityIcons.tsx +157 -0
  42. package/app/components/ui/index.ts +1 -0
  43. package/app/crons/PageClient.tsx +32 -7
  44. package/app/gateway/PageClient.tsx +104 -16
  45. package/bin/rev4a.js +35 -24
  46. package/docs/ARCHITECTURE.md +31 -14
  47. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  48. package/docs/REV4A.md +36 -6
  49. package/docs/dev/API-REFERENCE.md +13 -8
  50. package/docs/dev/GATEWAY.md +42 -1
  51. package/docs/dev/PROVIDERS.md +4 -3
  52. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  53. package/instrumentation.ts +18 -0
  54. package/lib/agent-setup.ts +8 -9
  55. package/lib/buildAgentImage.ts +5 -1
  56. package/lib/channelManager.ts +23 -16
  57. package/lib/db.ts +0 -10
  58. package/lib/docker-exec.ts +195 -0
  59. package/lib/docker-socket.ts +121 -0
  60. package/lib/docker-utils.ts +22 -48
  61. package/lib/openclaw-cron.ts +81 -48
  62. package/lib/openrouter-pricing.ts +184 -0
  63. package/lib/rev4a-auth.d.ts +4 -1
  64. package/lib/rev4a-auth.js +68 -20
  65. package/lib/rev4a-paths.ts +19 -0
  66. package/model-pricing.json +50 -50
  67. package/models.config.json +2 -2
  68. package/package.json +4 -1
  69. package/proxy.ts +68 -0
  70. package/scripts/refresh-model-pricing.mjs +171 -0
  71. package/lib/auth.ts +0 -28
  72. package/lib/requireAuth.tsx +0 -38
package/docs/REV4A.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rev4a — VPS Dashboard
2
2
 
3
- > **Last updated:** 2026-07-27
3
+ > **Last updated:** 2026-08-09
4
4
 
5
5
  A Next.js 16 dashboard for monitoring and managing the OpenClaw ecosystem.
6
6
 
@@ -52,15 +52,24 @@ A Next.js 16 dashboard for monitoring and managing the OpenClaw ecosystem.
52
52
 
53
53
  ### Environment
54
54
 
55
- Rev4a reads the following environment variables. Set them in `/config` (UI) or directly in `.env`.
55
+ Rev4a reads the following environment variables. Set them in `/config` (UI) or directly in `.env`. See [.env.example](../.env.example) for the canonical, always-current list.
56
56
 
57
57
  | Variable | Required | Description |
58
58
  |---|---|---|
59
59
  | `REV4A_PASSWORD` | Yes | Login password for the dashboard |
60
60
  | `REV4A_TOKEN` | Yes | Token used for programmatic API access |
61
61
  | `REV4A_JWT_SECRET` | Yes | Secret used to sign session JWT tokens |
62
- | `REV4A_DB` | No | Path to the events database (default: `data/events.db`) |
62
+ | `REV4A_API_KEY` | No | API key for the Gateway provider endpoint (auto-generated) |
63
+ | `GATEWAY_TOKEN` | No | Shared token handed to agent containers |
64
+ | `REV4A_DATA_DIR` | No | Root of all persistent data (default: `~/.config/rev4a`) |
65
+ | `REV4A_DB` | No | Path to the events database (default: `<data dir>/data/events.db`) |
66
+ | `REV4A_CREDENTIALS_DB` | No | Path to the credentials vault database |
63
67
  | `REV4A_WORKSPACE_ROOT` | No | Filesystem path for the local file explorer |
68
+ | `REV4A_ROOT` | No | Installation root, used by `rev4a update` to detect a git checkout |
69
+ | `REV4A_TIMEZONE` | No | IANA timezone used for scheduling and timestamps |
70
+ | `REV4A_FORCE_INSECURE_COOKIE` | No | Allow a non-Secure session cookie — plain-HTTP deployments only |
71
+
72
+ See [Alerts](#alerts-telegram) below for the Telegram alert variables.
64
73
 
65
74
  ## Agent creation
66
75
 
@@ -138,6 +147,29 @@ REV4A_JWT_SECRET=*** # Secret for signing JWTs
138
147
 
139
148
  ---
140
149
 
150
+ ## Alerts (Telegram)
151
+
152
+ Rev4a can send Telegram alerts when the events database goes stale (no
153
+ recent writes — see `/api/system-health`). Implemented in `lib/alerts.ts`.
154
+
155
+ **Env vars:**
156
+ ```
157
+ REV4A_ALERTS_ENABLED=false # Master switch — no alerts sent unless "true"
158
+ REV4A_TELEGRAM_BOT_TOKEN= # Telegram bot token
159
+ REV4A_TELEGRAM_CHAT_ID= # Telegram chat to notify
160
+ REV4A_ALERT_COOLDOWN_MS=600000 # Minimum time between repeat alerts for the same condition
161
+ REV4A_ALERT_STALE_SECONDS=120 # How long without a DB write before the system is considered stale
162
+ REV4A_ALERT_SMOKE=false # Set true to send a one-off test alert on startup
163
+ ```
164
+
165
+ **Behavior:**
166
+ - If `REV4A_ALERTS_ENABLED` is not `"true"`, `maybeSendAlert` is a no-op — nothing is sent, nothing is recorded.
167
+ - If enabled but `REV4A_TELEGRAM_BOT_TOKEN` or `REV4A_TELEGRAM_CHAT_ID` is missing, alerts run in **dry-run**: state transitions are still recorded in the `alert_state` table (SQLite), but no Telegram message is sent — visible in server logs as `[alerts] dry-run`.
168
+ - Alert state (`ok` / `alerted` / `resolved`) is tracked per `alert_key` in `alert_state`, so a resolved condition sends a follow-up "✅ resolved" message instead of staying silent.
169
+ - `REV4A_ALERT_COOLDOWN_MS` prevents repeat-sending the same stale alert while the condition persists.
170
+
171
+ ---
172
+
141
173
  ## Service Management
142
174
 
143
175
  ```bash
@@ -167,8 +199,6 @@ Environment=REV4A_PASSWORD=***
167
199
  Environment=REV4A_TOKEN=***
168
200
  Environment=REV4A_JWT_SECRET=***
169
201
  Environment=REV4A_DB=/path/to/rev4a/data/events.db
170
- Environment=OPENCLAW_CONFIG_PATH=/path/to/openclaw-core.json
171
- Environment=SHARED_CONTEXT_DIR=/path/to/shared-context
172
202
  Environment=NODE_ENV=production
173
203
  Environment=NEXT_TELEMETRY_DISABLED=1
174
204
  ```
@@ -498,7 +528,7 @@ curl -b /tmp/cookies.txt -X PUT http://localhost:3740/api/workspace \
498
528
  ├── public/ # Static assets
499
529
  ├── scripts/ # Utility scripts (setup, helpers)
500
530
  ├── .env # Local env vars (gitignored)
501
- ├── proxy.ts # Auth proxy
531
+ ├── proxy.ts # Auth gate — redirects to /login without a valid JWT cookie
502
532
  ├── next.config.mjs
503
533
  └── package.json
504
534
  ```
@@ -197,6 +197,11 @@ Returns current provider configuration state.
197
197
 
198
198
  **Auth:** any auth method
199
199
 
200
+ **Query params:**
201
+ | Param | Effect |
202
+ |---|---|
203
+ | `summary=1` | Returns only `{ provider, label, configured }` per provider — no `models`, no `pricing`, no `apiKey`. The full response awaits live pricing from OpenRouter, a network round trip costing ~2.4s on a cold cache; callers that only need to know whether *any* provider is configured should use this. |
204
+
200
205
  **Response:**
201
206
  ```json
202
207
  {
@@ -543,9 +548,9 @@ List running agent containers (Docker containers with `AGENT_ID` label) with ful
543
548
  "ports": "",
544
549
  "ip": "172.19.0.5",
545
550
  "created": "2026-07-01T16:58:42.876829416Z",
546
- "env": ["AGENT_ID=prometheus", "AGENT_HOSTNAME=<agent-name>.<your-domain>.com"],
551
+ "env": ["AGENT_ID=prometheus", "AGENT_TEMPLATE=prometheus"],
547
552
  "authToken": "***",
548
- "traefikUrl": "http://187.77.156.41:3033#token=asdfghjkl"
553
+ "controlPort": "3033"
549
554
  }
550
555
  ]
551
556
 
@@ -584,7 +589,7 @@ Create a new agent container from a template.
584
589
  **Every agent is created with a host port mapping:**
585
590
  - Port block: `-p <start>-<end>:3000-<3000+offset>` — maps a range of host ports to the same range starting at container port 3000
586
591
  - Single port: `-p <port>:3000` — maps a single host port to container port 3000
587
- - URL format: `http://<host-ip>:<portStart>#token=...`
592
+ - Control UI URL: composed by the browser as `http://<window.location.hostname>:<port>#token=<controlToken>` — the server returns the parts, not the URL
588
593
  - Port 3740 is reserved for Rev4a
589
594
 
590
595
  **Response:**
@@ -595,7 +600,7 @@ Create a new agent container from a template.
595
600
  "name": "my-agent",
596
601
  "image": "openclaw-agent-base:latest",
597
602
  "network": "rev4a-network",
598
- "traefikUrl": "http://187.77.156.41:3700#token=asdfghjkl",
603
+ "controlToken": "asdfghjkl",
599
604
  "port": 3700,
600
605
  "portEnd": 3709,
601
606
  "isBlock": true,
@@ -1562,7 +1567,7 @@ Enable or disable a plugin.
1562
1567
  ## Skills
1563
1568
 
1564
1569
  Skill discovery across three sources:
1565
- - **Shared:** skills in `/docker/shared-skills` on the VPS host (global, available to all agents)
1570
+ - **Shared:** skills in `~/.config/rev4a/shared/shared-skills` on the VPS host (global, available to all agents)
1566
1571
  - **Workspace:** skills in `<workspace>/skills` inside each agent container (discovered via `docker exec`)
1567
1572
  - **Bundled:** skills shipped with OpenClaw (read-only)
1568
1573
 
@@ -1578,8 +1583,8 @@ List all available skills from all sources.
1578
1583
  {
1579
1584
  "name": "code-review",
1580
1585
  "type": "shared",
1581
- "path": "/docker/shared-skills/code-review",
1582
- "skillMdPath": "/docker/shared-skills/code-review/SKILL.md",
1586
+ "path": "~/.config/rev4a/shared/shared-skills/code-review",
1587
+ "skillMdPath": "~/.config/rev4a/shared/shared-skills/code-review/SKILL.md",
1583
1588
  "hasSkillMd": true,
1584
1589
  "description": "Systematic code review patterns...",
1585
1590
  "version": "1.0"
@@ -1643,7 +1648,7 @@ Copy a skill from an agent's workspace to the shared skills directory, making it
1643
1648
  "skillName": "my-custom-skill",
1644
1649
  "agentId": "atlas",
1645
1650
  "containerName": "openclaw-atlas",
1646
- "destPath": "/docker/shared-skills/my-custom-skill",
1651
+ "destPath": "~/.config/rev4a/shared/shared-skills/my-custom-skill",
1647
1652
  "overwritten": false,
1648
1653
  "extraCopied": 0
1649
1654
  }
@@ -227,6 +227,11 @@ Updates model config for a single agent container (primary model only).
227
227
 
228
228
  Returns current provider configuration state.
229
229
 
230
+ Add `?summary=1` for a cheap variant — `{ provider, label, configured }` only,
231
+ skipping the live-pricing fetch. Used by the agents page, which needs nothing
232
+ more than "is any provider configured" and shouldn't block on a network call to
233
+ OpenRouter to find out.
234
+
230
235
  **Response:**
231
236
  ```json
232
237
  {
@@ -237,13 +242,49 @@ Returns current provider configuration state.
237
242
  "configured": true,
238
243
  "baseUrl": "https://api.deepseek.com",
239
244
  "models": [
240
- { "id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", "enabled": true }
245
+ {
246
+ "id": "deepseek/deepseek-v4-flash",
247
+ "name": "DeepSeek V4 Flash",
248
+ "enabled": true,
249
+ "pricing": { "input": 0.05, "output": 0.10 },
250
+ "pricingLive": true,
251
+ "modality": "text->text"
252
+ }
241
253
  ]
242
254
  }
243
255
  ]
244
256
  }
245
257
  ```
246
258
 
259
+ `pricing` is USD per 1M tokens. `-1 / -1` means the model routes dynamically
260
+ (OpenRouter Auto) — the UI renders "Dynamic" and shows "varies" for
261
+ capabilities rather than concrete icons.
262
+
263
+ #### Live pricing
264
+
265
+ `model-pricing.json` is maintained by hand and drifts — 16 of 79 OpenRouter
266
+ entries were wrong when this was added, one understating the real cost ~8x.
267
+ `lib/openrouter-pricing.ts` overlays the live catalogue on top of it and sets
268
+ `pricingLive: true` on the models it covers, which the UI marks with a `LIVE`
269
+ pill.
270
+
271
+ - Endpoint: `GET https://openrouter.ai/api/v1/models` — public, no API key,
272
+ CDN-cached, never billed.
273
+ - Scope: **only** models whose provider is `openrouter`. The same model sold
274
+ direct (openai, anthropic, google…) has different rates, so applying
275
+ OpenRouter's price there would show the wrong vendor's number.
276
+ - Cache mirrors what the endpoint itself declares: `max-age=300` for
277
+ freshness, `stale-if-error=3600` as the upper bound past which a stale
278
+ entry is dropped in favour of the static file. Failures back off for a
279
+ minute instead of retrying on every request.
280
+ - Skipped entirely when OpenRouter isn't configured, so callers that only
281
+ read `configured` (the agents page) don't wait on a network round trip.
282
+ - `modality` is **not** taken from upstream. `sync.ts` provisions agent
283
+ containers from the curated `models.config.json` value, so a different
284
+ capability set in the UI would advertise inputs the container never
285
+ accepted. (`moonshotai/kimi-k3` currently differs: `text+image` curated
286
+ vs `text+image+video` upstream.)
287
+
247
288
  ---
248
289
 
249
290
  ## Config Page (`/config`)
@@ -218,9 +218,10 @@ Key points:
218
218
 
219
219
  ## Security Notes
220
220
 
221
- - The `GET /api/vault/provider/key` endpoint is protected by the authentication
222
- middleware (browser cookie or bearer token). Only authenticated users can reveal
223
- keys.
221
+ - The `GET /api/vault/provider/key` endpoint calls `requireAuthJWT` directly
222
+ (browser cookie or bearer token) — API routes are never covered by `proxy.ts`
223
+ (it explicitly exempts `/api/*`), so each route must guard itself. Only
224
+ authenticated users can reveal keys.
224
225
  - The UI **must never** log, screenshot, or otherwise persist the revealed key
225
226
  outside the active screen view.
226
227
  - All secrets in documentation, logs, or reports must be masked (e.g. `sk-...ast4`).
@@ -87,7 +87,7 @@ Browse installed OpenClaw plugins. Enable/disable toggle per plugin. Plugins ext
87
87
 
88
88
  ### Skills (`/skills`)
89
89
  Skill registry showing all skills across three sources:
90
- - **Shared** — global skills from `/docker/shared-skills`, available to all agents
90
+ - **Shared** — global skills from `~/.config/rev4a/shared/shared-skills`, available to all agents
91
91
  - **Per-agent** — skills in each agent's workspace (`<workspace>/skills`), shown with agent label
92
92
  - **Bundled** — skills shipped with OpenClaw (read-only)
93
93
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Next.js instrumentation hook — runs once at server startup.
3
+ *
4
+ * Syncs the provider gateway baseUrl to all agent containers on boot.
5
+ * This ensures agents pick up any domain/env changes after a restart,
6
+ * VPS reboot, or crash recovery — no manual Sync button needed.
7
+ */
8
+ export async function register() {
9
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
10
+ try {
11
+ const { syncAllAgents } = await import('./app/api/gateway/sync');
12
+ syncAllAgents();
13
+ } catch {
14
+ // Best-effort — don't block startup if sync fails
15
+ console.warn('[rev4a] Failed to sync agents on startup');
16
+ }
17
+ }
18
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { execSync } from 'child_process';
12
12
  import * as fs from 'fs';
13
+ import { SHARED_SKILLS_DIR, REV4A_RULES_DIR } from '@/lib/rev4a-paths';
13
14
 
14
15
  // ── Volumes ──────────────────────────────────────────────────────────────────
15
16
 
@@ -28,12 +29,12 @@ export interface Rev4aVolume {
28
29
  */
29
30
  export const REV4A_VOLUMES: Rev4aVolume[] = [
30
31
  {
31
- source: '/docker/shared-skills',
32
+ source: SHARED_SKILLS_DIR,
32
33
  target: '/data/.openclaw/shared-skills',
33
34
  mode: 'ro',
34
35
  },
35
36
  {
36
- source: '/docker/rev4a-rules',
37
+ source: REV4A_RULES_DIR,
37
38
  target: '/root/.openclaw/workspace/.rev4a',
38
39
  mode: 'ro',
39
40
  },
@@ -60,14 +61,12 @@ export function getMountFlags(): string[] {
60
61
  * multiple times.
61
62
  */
62
63
  export function ensureHostDirs(): void {
64
+ // Plain mkdir: these now live under the user's own data directory, so no
65
+ // elevation is needed. The previous `sudo mkdir` with a plain fallback was
66
+ // only required because the paths sat at the filesystem root, and it could
67
+ // hang on a password prompt when run non-interactively.
63
68
  for (const vol of REV4A_VOLUMES) {
64
- if (!fs.existsSync(vol.source)) {
65
- try {
66
- execSync(`sudo mkdir -p ${escapeShell(vol.source)}`, { stdio: 'pipe' });
67
- } catch {
68
- execSync(`mkdir -p ${escapeShell(vol.source)}`, { stdio: 'pipe' });
69
- }
70
- }
69
+ fs.mkdirSync(vol.source, { recursive: true });
71
70
  }
72
71
  }
73
72
 
@@ -193,7 +193,11 @@ async function pullFromRegistry(
193
193
 
194
194
  child.on('error', (err: Error) => {
195
195
  appendLog(`Pull process error: ${err.message}`);
196
- releaseLock();
196
+ // Do NOT release the lock here: the caller falls through to
197
+ // localBuild(), which must stay protected. Releasing early let a
198
+ // concurrent request past getIsDownloading() and start a second
199
+ // `docker build` against the same tag. The lock is released by
200
+ // localBuild's own close/error handlers, or by the success path.
197
201
  resolve(false);
198
202
  });
199
203
  });
@@ -97,6 +97,28 @@ function findContainerById(agentId: string): string | null {
97
97
 
98
98
  // ── Public API ───────────────────────────────
99
99
 
100
+ /**
101
+ * Read the Telegram channel out of an already-parsed openclaw.json.
102
+ *
103
+ * Pure — no container access — so callers that have obtained the config some
104
+ * other way (in bulk, asynchronously) can share the same interpretation instead
105
+ * of reimplementing it.
106
+ */
107
+ export function parseTelegramChannel(config: Record<string, unknown>): TelegramChannel | null {
108
+ const telegram = (config.channels as Record<string, unknown>)?.telegram as Record<string, unknown> | undefined;
109
+ if (!telegram || telegram.enabled === false) return null;
110
+
111
+ const botToken = (telegram.botToken ?? '') as string;
112
+ if (typeof botToken !== 'string' || botToken.length === 0) return null;
113
+
114
+ return {
115
+ connected: true,
116
+ botToken: maskToken(botToken),
117
+ dmPolicy: (telegram.dmPolicy ?? 'pairing') as string,
118
+ allowFrom: (Array.isArray(telegram.allowFrom) ? telegram.allowFrom : []) as string[],
119
+ };
120
+ }
121
+
100
122
  export function getChannels(agentId: string): AgentChannels {
101
123
  const container = findContainerById(agentId);
102
124
  const result: AgentChannels = { agentId, telegram: null };
@@ -106,22 +128,7 @@ export function getChannels(agentId: string): AgentChannels {
106
128
  const config = readConfigJson(container);
107
129
  if (!config) return result;
108
130
 
109
- // ── Telegram ──
110
- const telegram = (config.channels as Record<string, unknown>)?.telegram as Record<string, unknown> | undefined;
111
- if (telegram && telegram.enabled !== false) {
112
- const botToken = (telegram.botToken ?? '') as string;
113
- const hasToken = typeof botToken === 'string' && botToken.length > 0;
114
-
115
- if (hasToken) {
116
- result.telegram = {
117
- connected: true,
118
- botToken: maskToken(botToken),
119
- dmPolicy: (telegram.dmPolicy ?? 'pairing') as string,
120
- allowFrom: (Array.isArray(telegram.allowFrom) ? telegram.allowFrom : []) as string[],
121
- };
122
- }
123
- }
124
-
131
+ result.telegram = parseTelegramChannel(config);
125
132
  return result;
126
133
  }
127
134
 
package/lib/db.ts CHANGED
@@ -1,19 +1,9 @@
1
1
  // Shared DB helpers for API routes
2
2
  import Database from 'better-sqlite3';
3
- import type { NextRequest } from 'next/server';
4
- import { NextResponse } from 'next/server';
5
3
  import { initializeRev4aDb } from './db-bootstrap.mjs';
6
4
  import { DB_FILE } from './rev4a-paths';
7
5
 
8
6
  export const DB_PATH = process.env.REV4A_DB || DB_FILE;
9
- export const TOKEN = process.env.REV4A_TOKEN ?? '';
10
-
11
- export function requireAuth(request: NextRequest): NextResponse | null {
12
- const token = new URL(request.url).searchParams.get('token');
13
- const auth = request.headers.get('authorization');
14
- const ok = token === TOKEN || auth === `Bearer ${TOKEN}`;
15
- return ok ? null : NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16
- }
17
7
 
18
8
  export function ensureDbReady(): void {
19
9
  initializeRev4aDb(DB_PATH);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Running commands inside agent containers, without blocking the server.
3
+ *
4
+ * Every route that needed something from a container used to call `execSync` or
5
+ * `execFileSync` directly. Those are synchronous: while one runs, Node's single
6
+ * thread does nothing else — no other request is served, no agent's stream
7
+ * advances. Node's own guidance names `execSync` in a server as a
8
+ * denial-of-service vector, and the numbers here bear that out: a bare
9
+ * `docker exec … echo` costs ~260ms, and the OpenClaw CLI inside a container
10
+ * takes 2.5–6s per invocation because it boots a whole Node process.
11
+ *
12
+ * Routes that walk every agent multiplied that: `/api/crons` measured 19.2s on a
13
+ * seven-agent host, all of it with the event loop frozen.
14
+ *
15
+ * Everything here is async. The commands are still as slow as they were — that
16
+ * cost belongs to the OpenClaw CLI — but they no longer stop the rest of the
17
+ * server while they run.
18
+ */
19
+ import { execFile, spawn } from 'child_process';
20
+ import { promisify } from 'util';
21
+ import { resolveDockerSocket } from '@/lib/docker-socket';
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ /** Commands are expected to answer quickly; the OpenClaw CLI needs the headroom. */
26
+ const DEFAULT_TIMEOUT_MS = 15_000;
27
+
28
+ /** Matches what the previous callers passed, and covers `openclaw models list --json`. */
29
+ const DEFAULT_MAX_BUFFER = 1024 * 1024;
30
+
31
+ export interface DockerExecOptions {
32
+ timeoutMs?: number;
33
+ /** Extra environment for the command, passed as `-e KEY=VALUE`. */
34
+ env?: Record<string, string>;
35
+ maxBuffer?: number;
36
+ signal?: AbortSignal;
37
+ }
38
+
39
+ function baseArgs(container: string, opts?: DockerExecOptions): string[] {
40
+ const args = ['exec'];
41
+ for (const [k, v] of Object.entries(opts?.env ?? {})) args.push('-e', `${k}=${v}`);
42
+ args.push(container);
43
+ return args;
44
+ }
45
+
46
+ /**
47
+ * Run a command inside a container, passing argv directly.
48
+ *
49
+ * Prefer this over {@link dockerExecShell}: with no shell in between there is
50
+ * nothing to quote and nothing to escape, so a filename with a space or a quote
51
+ * cannot turn into a second command.
52
+ *
53
+ * Rejects when the command fails, the container is missing, or the timeout hits.
54
+ */
55
+ export async function dockerExec(
56
+ container: string,
57
+ argv: string[],
58
+ opts?: DockerExecOptions,
59
+ ): Promise<string> {
60
+ if (!resolveDockerSocket()) throw new Error('Docker is not available');
61
+
62
+ const { stdout } = await execFileAsync(
63
+ 'docker',
64
+ [...baseArgs(container, opts), ...argv],
65
+ {
66
+ encoding: 'utf-8',
67
+ timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
68
+ maxBuffer: opts?.maxBuffer ?? DEFAULT_MAX_BUFFER,
69
+ signal: opts?.signal,
70
+ },
71
+ );
72
+ return stdout;
73
+ }
74
+
75
+ /**
76
+ * Run a shell command inside a container. Only for commands that genuinely need
77
+ * a shell — pipes, redirection, globbing. Anything interpolated into `cmd` is
78
+ * shell syntax, so callers must quote it themselves.
79
+ */
80
+ export async function dockerExecShell(
81
+ container: string,
82
+ cmd: string,
83
+ opts?: DockerExecOptions,
84
+ ): Promise<string> {
85
+ return dockerExec(container, ['sh', '-c', cmd], opts);
86
+ }
87
+
88
+ /**
89
+ * As {@link dockerExec}, but resolves to '' instead of rejecting.
90
+ *
91
+ * This is the shape most of the existing callers want: a container that is
92
+ * stopped, or an OpenClaw subcommand that isn't available on that agent, is a
93
+ * normal condition here — not something to fail a whole page over.
94
+ */
95
+ export async function dockerExecNoFail(
96
+ container: string,
97
+ argv: string[],
98
+ opts?: DockerExecOptions,
99
+ ): Promise<string> {
100
+ try {
101
+ return await dockerExec(container, argv, opts);
102
+ } catch {
103
+ return '';
104
+ }
105
+ }
106
+
107
+ /** Shell variant of {@link dockerExecNoFail}. */
108
+ export async function dockerExecShellNoFail(
109
+ container: string,
110
+ cmd: string,
111
+ opts?: DockerExecOptions,
112
+ ): Promise<string> {
113
+ return dockerExecNoFail(container, ['sh', '-c', cmd], opts);
114
+ }
115
+
116
+ /**
117
+ * Run a command inside a container and write `input` to its stdin.
118
+ *
119
+ * This uses `spawn` rather than `execFile` on purpose: `promisify(exec)` and
120
+ * `promisify(execFile)` silently ignore an `input` option — the process starts,
121
+ * stdin is never written, and the command hangs or reads nothing, with no error
122
+ * to point at. `openclaw config patch --stdin` is the case that needs this.
123
+ */
124
+ export function dockerExecWithInput(
125
+ container: string,
126
+ argv: string[],
127
+ input: string,
128
+ opts?: DockerExecOptions,
129
+ ): Promise<string> {
130
+ if (!resolveDockerSocket()) return Promise.reject(new Error('Docker is not available'));
131
+
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawn('docker', ['exec', '-i', ...baseArgs(container, opts).slice(1), ...argv], {
134
+ stdio: ['pipe', 'pipe', 'pipe'],
135
+ });
136
+
137
+ let stdout = '';
138
+ let stderr = '';
139
+ const timer = setTimeout(() => {
140
+ child.kill('SIGKILL');
141
+ reject(new Error(`docker exec timed out after ${opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
142
+ }, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
143
+
144
+ child.stdout.on('data', (c) => { stdout += c; });
145
+ child.stderr.on('data', (c) => { stderr += c; });
146
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
147
+ child.on('close', (code) => {
148
+ clearTimeout(timer);
149
+ if (code === 0) resolve(stdout);
150
+ else reject(new Error(stderr.trim() || `docker exec exited with code ${code}`));
151
+ });
152
+
153
+ child.stdin.on('error', () => { /* the close handler reports the real failure */ });
154
+ child.stdin.end(input);
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Map over items with a cap on how many run at once.
160
+ *
161
+ * The point of making these calls async is to run an agent fleet's worth of them
162
+ * concurrently instead of one after another. Doing that with a bare
163
+ * `Promise.all` would spawn one `docker exec` per agent simultaneously, which is
164
+ * fine for seven agents and not fine for seventy — each one is a process, and
165
+ * the Docker daemon serialises past a point anyway. The cap keeps the win
166
+ * without trading a frozen event loop for a swamped daemon.
167
+ *
168
+ * Results keep the order of `items`, regardless of completion order.
169
+ *
170
+ * Fail-fast, like `Promise.all`: if `fn` rejects for one item the whole call
171
+ * rejects. That is deliberate — silently swallowing failures here would hide
172
+ * real errors — but it means callers fanning out over an agent fleet should pass
173
+ * a function that handles its own failures (the `…NoFail` variants above, or an
174
+ * explicit try/catch), so one unreachable container can't blank an entire page.
175
+ */
176
+ export async function mapWithConcurrency<T, R>(
177
+ items: readonly T[],
178
+ limit: number,
179
+ fn: (item: T, index: number) => Promise<R>,
180
+ ): Promise<R[]> {
181
+ if (items.length === 0) return [];
182
+ const results = new Array<R>(items.length);
183
+ let next = 0;
184
+
185
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
186
+ while (true) {
187
+ const i = next++;
188
+ if (i >= items.length) return;
189
+ results[i] = await fn(items[i], i);
190
+ }
191
+ });
192
+
193
+ await Promise.all(workers);
194
+ return results;
195
+ }