@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
@@ -21,7 +21,8 @@
21
21
  * - gateway/config-channels.md
22
22
  * - concepts/multi-agent.md (bindings)
23
23
  */
24
- import { execSync, execFileSync } from 'child_process';
24
+ import { execSync } from 'child_process';
25
+ import { dockerExec, dockerExecShellNoFail } from '@/lib/docker-exec';
25
26
 
26
27
  // ── Types ────────────────────────────────────
27
28
 
@@ -134,19 +135,22 @@ export function getChannels(agentId: string): AgentChannels {
134
135
 
135
136
  // ── Telegram ─────────────────────────────────
136
137
 
137
- export function setTelegramConfig(
138
+ export async function setTelegramConfig(
138
139
  agentId: string,
139
140
  params: {
140
141
  botToken: string;
141
142
  dmPolicy?: string;
142
143
  allowFrom?: string[];
143
144
  },
144
- ): { success: boolean; error?: string } {
145
+ ): Promise<{ success: boolean; error?: string }> {
145
146
  const container = findContainerById(agentId);
146
147
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
147
148
 
148
149
  try {
149
- const config = readConfigJson(container) ?? {};
150
+ // A failed read is not an empty config: writing `{}` back would replace the agent's
151
+ // whole openclaw.json with the channel block (the historical defect this route had).
152
+ const config = readConfigJson(container);
153
+ if (!config) return { success: false, error: 'Could not read the agent configuration' };
150
154
 
151
155
  // Ensure channels.telegram structure
152
156
  if (!config.channels) config.channels = {};
@@ -161,10 +165,7 @@ export function setTelegramConfig(
161
165
  // If token changed, wipe old pairing data to avoid orphaned allowFrom entries
162
166
  const oldToken = existing.botToken as string | undefined;
163
167
  if (oldToken && oldToken !== params.botToken && (existing.dmPolicy as string) === 'pairing') {
164
- containerExecNoFail(
165
- container,
166
- `rm -f ${pairingFiles('telegram')} 2>/dev/null`,
167
- );
168
+ await clearChannelAllowlist(container, 'telegram');
168
169
  }
169
170
 
170
171
  // Merge channel config: preserve ALL existing keys (provider, etc.)
@@ -190,7 +191,7 @@ export function setTelegramConfig(
190
191
  }
191
192
  }
192
193
 
193
- export function removeTelegram(agentId: string): { success: boolean; error?: string } {
194
+ export async function removeTelegram(agentId: string): Promise<{ success: boolean; error?: string }> {
194
195
  const container = findContainerById(agentId);
195
196
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
196
197
 
@@ -210,11 +211,8 @@ export function removeTelegram(agentId: string): { success: boolean; error?: str
210
211
  }
211
212
  }
212
213
 
213
- // Clean up pairing files — they belong to the disconnected bot
214
- containerExecNoFail(
215
- container,
216
- `rm -f ${pairingFiles('telegram')} 2>/dev/null`,
217
- );
214
+ // The approved senders belong to the disconnected bot: clear the pairing store.
215
+ await clearChannelAllowlist(container, 'telegram');
218
216
 
219
217
  removeBindings(config, agentId, 'telegram');
220
218
  writeConfigJson(container, config);
@@ -291,22 +289,125 @@ export interface PairingState {
291
289
  approved: ApprovedPairing[];
292
290
  }
293
291
 
294
- function pairingAllowFile(channel: string): string {
295
- return `/root/.openclaw/credentials/${channel}-default-allowFrom.json`;
292
+ /**
293
+ * Script run inside the container to read or edit OpenClaw's channel pairing store.
294
+ *
295
+ * On 2026.9.3 the approved senders live in `~/.openclaw/state/openclaw.sqlite`
296
+ * (`channel_pairing_allow_entries`), and no CLI, RPC or documented export reads or
297
+ * writes them: `openclaw pairing` covers pending requests only, `channels.pairing.*`
298
+ * has no remove, and the writers sit in `dist/pairing-store-<hash>.mjs` with minified
299
+ * export names. The function *names* are preserved, though, so the module is found by
300
+ * glob and its functions by `name` — no hash or minified symbol is hardcoded. The
301
+ * write goes through the same state transaction the CLI uses.
302
+ *
303
+ * `op`, `channel` and `entry` arrive as argv, never interpolated into the source.
304
+ * The sentinel lines are machine-readable: `docker exec` exits 0 even when killed by
305
+ * the timeout, so an empty result cannot be read as success (docs/ARCHITECTURE.md §3.1).
306
+ */
307
+ const PAIRING_STORE_SCRIPT = `
308
+ (async () => {
309
+ const fs = require('fs');
310
+ const path = require('path');
311
+ const { pathToFileURL } = require('url');
312
+ const [op, channel, entry] = process.argv.slice(1);
313
+ const dist = '/usr/local/lib/node_modules/openclaw/dist';
314
+ let remove, readSync;
315
+ try {
316
+ for (const f of fs.readdirSync(dist).filter((x) => /^pairing-store-.*\\.mjs$/.test(x))) {
317
+ const mod = await import(pathToFileURL(path.join(dist, f)).href);
318
+ for (const v of Object.values(mod)) {
319
+ if (typeof v !== 'function') continue;
320
+ if (v.name === 'removeChannelAllowFromStoreEntry') remove = v;
321
+ if (v.name === 'readChannelAllowFromStoreSync') readSync = v;
322
+ }
323
+ }
324
+ } catch (e) { console.log('pair:error ' + e.message); return; }
325
+ if (!remove || !readSync) { console.log('pair:unsupported'); return; }
326
+ // Entries are sender ids as strings today; anything else has a field to read. An
327
+ // unexpected shape is an error, never String(entry) — that would print
328
+ // "[object Object]" and then remove nothing while reporting success.
329
+ const normalize = (list) => (list || []).map((e) => {
330
+ if (typeof e === 'string') return e;
331
+ if (e && typeof e === 'object') { const v = e.id ?? e.senderId ?? e.entry; return typeof v === 'string' ? v : null; }
332
+ return null;
333
+ });
334
+ try {
335
+ if (op === 'read') {
336
+ const entries = normalize(readSync(channel));
337
+ if (entries.some((e) => e === null)) { console.log('pair:error unexpected allowlist entry shape'); return; }
338
+ console.log('pair:list ' + JSON.stringify(entries));
339
+ } else if (op === 'remove') {
340
+ const r = await remove({ channel, entry });
341
+ console.log(r && r.changed ? 'pair:ok' : 'pair:absent');
342
+ } else if (op === 'clear') {
343
+ const entries = normalize(readSync(channel));
344
+ if (entries.some((e) => e === null)) { console.log('pair:error unexpected allowlist entry shape'); return; }
345
+ for (const e of entries) await remove({ channel, entry: e });
346
+ console.log('pair:ok');
347
+ } else {
348
+ console.log('pair:error unknown op');
349
+ }
350
+ } catch (e) { console.log('pair:error ' + e.message); }
351
+ })();
352
+ `;
353
+
354
+ interface StoreOpResult {
355
+ status: 'ok' | 'absent' | 'unsupported' | 'error';
356
+ entries?: string[];
357
+ error?: string;
358
+ }
359
+
360
+ /** Channel ids reach shell commands and the store API; keep them to OpenClaw's shape. */
361
+ function isSafeChannel(channel: string): boolean {
362
+ return /^[a-z0-9_-]+$/.test(channel);
363
+ }
364
+
365
+ /** Run one pairing-store operation inside the container; see PAIRING_STORE_SCRIPT. */
366
+ async function runPairingStore(container: string, op: 'read' | 'remove' | 'clear', channel: string, entry?: string): Promise<StoreOpResult> {
367
+ const argv = ['node', '-e', PAIRING_STORE_SCRIPT, op, channel];
368
+ if (entry !== undefined) argv.push(entry);
369
+ let out: string;
370
+ try {
371
+ // argv reaches docker as an array — the script, the op and the sender id are
372
+ // never shell syntax. Async: the script loads OpenClaw's own modules inside the
373
+ // container and takes a second, which must not freeze Rev4a's event loop while
374
+ // the panel polls it.
375
+ out = (await dockerExec(container, argv, { timeoutMs: 20_000 })).trim();
376
+ } catch (e) {
377
+ return { status: 'error', error: (e as Error).message };
378
+ }
379
+ const listLine = out.split('\n').find((l) => l.startsWith('pair:list '));
380
+ if (listLine) {
381
+ try {
382
+ return { status: 'ok', entries: JSON.parse(listLine.slice('pair:list '.length)) as string[] };
383
+ } catch {
384
+ return { status: 'error', error: 'Could not read the approved senders list' };
385
+ }
386
+ }
387
+ if (out.includes('pair:ok')) return { status: 'ok' };
388
+ if (out.includes('pair:absent')) return { status: 'absent' };
389
+ if (out.includes('pair:unsupported')) return { status: 'unsupported' };
390
+ const errLine = out.split('\n').find((l) => l.startsWith('pair:error '));
391
+ return { status: 'error', error: errLine ? errLine.slice('pair:error '.length) : (out || 'Pairing store command produced no output') };
296
392
  }
297
393
 
298
- function pairingFiles(channel: string): string {
299
- return `/root/.openclaw/credentials/${channel}-pairing.json /root/.openclaw/credentials/${channel}-default-allowFrom.json`;
394
+ /** Drop every approved sender of a channel — a different bot must not inherit them. */
395
+ async function clearChannelAllowlist(container: string, channel: string): Promise<void> {
396
+ if (!isSafeChannel(channel)) return;
397
+ const result = await runPairingStore(container, 'clear', channel);
398
+ if (result.status === 'error') {
399
+ console.warn(`[channels] could not clear the ${channel} allowlist: ${result.error}`);
400
+ }
300
401
  }
301
402
 
302
403
  /** Get pending + approved pairings for a channel */
303
- export function getPairings(agentId: string, channel: string = 'telegram'): PairingState {
404
+ export async function getPairings(agentId: string, channel: string = 'telegram'): Promise<PairingState> {
304
405
  const container = findContainerById(agentId);
305
406
  const fallback: PairingState = { pending: [], approved: [] };
306
- if (!container) return fallback;
407
+ if (!container || !isSafeChannel(channel)) return fallback;
307
408
 
308
409
  // Pending
309
- const pendingRaw = containerExecNoFail(container, `openclaw pairing list --channel ${channel} --json 2>/dev/null`);
410
+ const pendingRaw = await dockerExecShellNoFail(container, `openclaw pairing list --channel ${channel} --json 2>/dev/null`);
310
411
  const pending: PendingPairing[] = [];
311
412
  if (pendingRaw) {
312
413
  try {
@@ -320,33 +421,25 @@ export function getPairings(agentId: string, channel: string = 'telegram'): Pair
320
421
  } catch { /* ignore */ }
321
422
  }
322
423
 
323
- // Approved: read from OpenClaw credentials file
324
- const allowRaw = containerExecNoFail(container, `cat ${pairingAllowFile(channel)} 2>/dev/null`);
424
+ // Approved: OpenClaw's pairing store (2026.9.x keeps it in SQLite; the legacy
425
+ // credentials files it replaced are gone, so reading those returned nothing).
325
426
  const approved: ApprovedPairing[] = [];
326
- if (allowRaw) {
327
- try {
328
- const parsed = JSON.parse(allowRaw);
329
- const entries = parsed?.allowFrom ?? [];
330
- for (const entry of entries) {
331
- if (typeof entry === 'string') {
332
- approved.push({ senderId: entry });
333
- } else if (entry?.senderId || entry?.id) {
334
- approved.push({ senderId: entry.senderId ?? entry.id });
335
- }
336
- }
337
- } catch { /* ignore */ }
427
+ const store = await runPairingStore(container, 'read', channel);
428
+ if (store.status === 'ok') {
429
+ for (const entry of store.entries ?? []) approved.push({ senderId: String(entry) });
338
430
  }
339
431
 
340
432
  return { pending, approved };
341
433
  }
342
434
 
343
435
  /** Approve a pending pairing code */
344
- export function approvePairing(agentId: string, code: string, channel: string = 'telegram'): { success: boolean; error?: string } {
436
+ export async function approvePairing(agentId: string, code: string, channel: string = 'telegram'): Promise<{ success: boolean; error?: string }> {
345
437
  const container = findContainerById(agentId);
346
438
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
439
+ if (!isSafeChannel(channel)) return { success: false, error: 'Invalid channel id' };
347
440
 
348
441
  try {
349
- const out = containerExecNoFail(container, `openclaw pairing approve ${channel} ${escapeShellArg(code)} 2>&1`);
442
+ const out = await dockerExecShellNoFail(container, `openclaw pairing approve ${channel} ${escapeShellArg(code)} 2>&1`);
350
443
  if (!out) {
351
444
  return { success: false, error: 'Command produced no output — pairing command may not be available' };
352
445
  }
@@ -360,76 +453,30 @@ export function approvePairing(agentId: string, code: string, channel: string =
360
453
  }
361
454
  }
362
455
 
363
- /**
364
- * Script run inside the container to drop one sender from the allowlist file.
365
- *
366
- * The sender id arrives as `process.argv[1]`, never interpolated into the source.
367
- * The previous version built this string by substitution, applying shell-style
368
- * escaping to a value that lands inside a JavaScript string literal: an id
369
- * containing an apostrophe closed the literal and the rest ran as code.
370
- *
371
- * It prints one of three sentinels so the caller can tell the outcomes apart —
372
- * `docker exec` exits 0 even when killed by the timeout, so an empty result
373
- * cannot be read as success (see docs/ARCHITECTURE.md §3.1).
374
- */
375
- const REVOKE_SCRIPT = `
376
- const fs = require('fs');
377
- const [p, target] = [process.argv[1], process.argv[2]];
378
- try {
379
- if (!fs.existsSync(p)) { console.log('revoke:no-store'); process.exit(0); }
380
- const d = JSON.parse(fs.readFileSync(p, 'utf8'));
381
- const before = (d.allowFrom || []).length;
382
- d.allowFrom = (d.allowFrom || []).filter((e) => String(e) !== target);
383
- if (d.allowFrom.length === before) { console.log('revoke:absent'); process.exit(0); }
384
- fs.writeFileSync(p, JSON.stringify(d, null, 2), 'utf8');
385
- console.log('revoke:ok');
386
- } catch (e) {
387
- console.log('revoke:error ' + e.message);
388
- }
389
- `;
390
-
391
456
  /**
392
457
  * Revoke an approved sender from the allowlist.
393
458
  *
394
- * There is no CLI for this. `openclaw pairing` exposes only `approve`, `list` and
395
- * `help` — verified on both 2026.7.1-2 and 2026.9.3 — so the allowlist store has
396
- * to be edited directly.
397
- *
398
- * The previous implementation tried `openclaw pairing revoke` first and treated
399
- * any output that lacked "Unknown command" or "not found" as success. OpenClaw
400
- * actually answers `OpenClaw does not know the command "revoke".`, which contains
401
- * neither, so every revocation reported success, changed nothing, and never
402
- * reached the fallback.
403
- *
404
- * On 2026.9.x this store moved into SQLite and the file is gone. Rather than
405
- * silently doing nothing again, that case now fails loudly. Revocation against 9.x's SQLite store is not
406
- * implemented yet.
459
+ * There is no CLI or RPC for this: `openclaw pairing` exposes only `approve`, `list`
460
+ * and `help`, and `channels.pairing.*` has no remove (verified on 2026.9.3). The
461
+ * store's own writer is called instead (`removeChannelAllowFromStoreEntry`, found by
462
+ * name — see PAIRING_STORE_SCRIPT). On a release that no longer exposes it, the
463
+ * failure is explicit: nothing is silently left unchanged (which is what the old
464
+ * `openclaw pairing revoke` probe did).
407
465
  */
408
- export function revokePairing(agentId: string, senderId: string, channel: string = 'telegram'): { success: boolean; error?: string } {
466
+ export async function revokePairing(agentId: string, senderId: string, channel: string = 'telegram'): Promise<{ success: boolean; error?: string }> {
409
467
  const container = findContainerById(agentId);
410
468
  if (!container) return { success: false, error: `Agent container not found: ${agentId}` };
411
-
412
- try {
413
- // execFileSync, not a shell: argv is passed through untouched, so nothing in
414
- // `senderId` can be read as syntax.
415
- const out = execFileSync(
416
- 'docker',
417
- ['exec', container, 'node', '-e', REVOKE_SCRIPT, pairingAllowFile(channel), senderId],
418
- { encoding: 'utf-8', timeout: 15000, maxBuffer: 1024 * 1024 },
419
- ).trim();
420
-
421
- if (out.includes('revoke:ok')) return { success: true };
422
- if (out.includes('revoke:absent')) return { success: true }; // already gone — the desired state
423
- if (out.includes('revoke:no-store')) {
424
- return {
425
- success: false,
426
- error: 'No allowlist file in this container: nobody has been approved on this '
427
- + 'channel yet, or the agent runs OpenClaw 9.x, whose pairing store is in '
428
- + 'SQLite and cannot be revoked from here yet.',
429
- };
430
- }
431
- return { success: false, error: out || 'revoke produced no output' };
432
- } catch (e) {
433
- return { success: false, error: (e as Error).message };
469
+ if (!isSafeChannel(channel)) return { success: false, error: 'Invalid channel id' };
470
+
471
+ const result = await runPairingStore(container, 'remove', channel, senderId);
472
+ if (result.status === 'ok') return { success: true };
473
+ if (result.status === 'absent') return { success: true }; // already gone — the desired state
474
+ if (result.status === 'unsupported') {
475
+ return {
476
+ success: false,
477
+ error: 'This OpenClaw release does not expose its pairing store to Rev4a. '
478
+ + 'Revoke the sender with `/allowlist remove` from the chat.',
479
+ };
434
480
  }
481
+ return { success: false, error: result.error ?? 'Revoke failed' };
435
482
  }