@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
@@ -1,11 +1,19 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { execSync, execFileSync } from 'child_process';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
5
6
  import { requireAuthJWT } from '@/lib/rev4a-auth';
7
+ import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
8
+ import { dockerExecShellNoFail, mapWithConcurrency } from '@/lib/docker-exec';
9
+
10
+ const execFileAsync = promisify(execFile);
6
11
 
7
- const SHARED_SKILLS_DIR = '/docker/shared-skills';
8
12
  const CONTAINER_BUNDLED_SKILLS_DIR = '/usr/local/lib/node_modules/openclaw/skills';
13
+ const WORKSPACE_SKILLS_DIR = '/root/.openclaw/workspace/skills';
14
+
15
+ /** Agents read at once — each is a single bulk `docker exec`. */
16
+ const AGENT_QUERY_CONCURRENCY = 6;
9
17
 
10
18
  function readSkillMeta(skillDir, name, type, agentId) {
11
19
  const skillMdPath = path.join(skillDir, name, 'SKILL.md');
@@ -41,22 +49,19 @@ function readSkillMeta(skillDir, name, type, agentId) {
41
49
  * Discover agent containers with AGENT_ID label.
42
50
  * Returns [{ containerName, agentId }]
43
51
  */
44
- function discoverAgentContainers() {
52
+ async function discoverAgentContainers() {
45
53
  try {
46
- // Get container names with AGENT_ID label
47
- const namesRaw = execFileSync('docker', [
48
- 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}'
49
- ], { encoding: 'utf8', timeout: 5000 }).trim();
50
- if (!namesRaw) return [];
51
-
52
- return namesRaw.split('\n').map(name => {
53
- const containerName = name.trim();
54
- if (!containerName) return null;
55
- // Get agent ID from label using docker inspect
56
- const agentId = execFileSync('docker', [
57
- 'inspect', containerName, '--format', '{{index .Config.Labels "AGENT_ID"}}'
58
- ], { encoding: 'utf8', timeout: 5000 }).trim();
59
- return { containerName, agentId: agentId || containerName };
54
+ // `docker ps` prints labels itself, so the AGENT_ID comes back with the name.
55
+ // This used to follow up with a `docker inspect` per container to read that
56
+ // same label — N+1 process spawns for data the first call already had.
57
+ const { stdout } = await execFileAsync('docker', [
58
+ 'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}'
59
+ ], { encoding: 'utf8', timeout: 5000 });
60
+
61
+ return stdout.trim().split('\n').map(line => {
62
+ const [agentId, containerName] = line.split('\t');
63
+ if (!containerName?.trim()) return null;
64
+ return { containerName: containerName.trim(), agentId: agentId?.trim() || containerName.trim() };
60
65
  }).filter(Boolean);
61
66
  } catch {
62
67
  return [];
@@ -64,42 +69,44 @@ function discoverAgentContainers() {
64
69
  }
65
70
 
66
71
  /**
67
- * Read skill directories from a container agent's workspace via docker exec.
68
- * Returns an array of skill names found.
72
+ * Read every SKILL.md under `dir` in one shot.
73
+ *
74
+ * The previous approach ran `docker exec … cat` once per skill. With 52 bundled
75
+ * skills in the base image that was 52 sequential process spawns at ~260ms each
76
+ * — roughly 13s of blocked event loop to read 52 text files sitting in the same
77
+ * container.
78
+ *
79
+ * The shell loop emits `path\0content\0` pairs. NUL is the delimiter precisely
80
+ * because it cannot occur inside the text files being read, so no SKILL.md can
81
+ * forge a record boundary — a risk any printable separator would carry.
82
+ *
83
+ * Returns a Map of skill name → SKILL.md contents.
69
84
  */
70
- function readContainerSkillDirs(containerName) {
71
- const workspaceSkillsPath = '/root/.openclaw/workspace/skills';
72
- try {
73
- // Check if the skills directory exists
74
- execFileSync('docker', ['exec', containerName, 'test', '-d', workspaceSkillsPath], {
75
- encoding: 'utf8', timeout: 5000
76
- });
77
-
78
- // List subdirectories (each is a potential skill)
79
- const raw = execFileSync('docker', ['exec', containerName, 'ls', '-1', workspaceSkillsPath], {
80
- encoding: 'utf8', timeout: 5000
81
- }).trim();
82
-
83
- if (!raw) return [];
84
-
85
- return raw.split('\n').map(line => line.trim()).filter(Boolean);
86
- } catch {
87
- return [];
88
- }
89
- }
90
-
91
- /**
92
- * Read SKILL.md content from inside a container.
93
- */
94
- function readContainerSkillMd(containerName, skillName) {
95
- const filePath = `/root/.openclaw/workspace/skills/${skillName}/SKILL.md`;
96
- try {
97
- return execFileSync('docker', ['exec', containerName, 'cat', filePath], {
98
- encoding: 'utf8', timeout: 5000
99
- });
100
- } catch {
101
- return null;
85
+ async function readSkillsBulk(containerName, dir) {
86
+ const script =
87
+ `for d in ${dir}/*/; do ` +
88
+ `[ -f "$d/SKILL.md" ] || continue; ` +
89
+ `n=$(basename "$d"); ` +
90
+ `printf '%s\\0' "$n"; ` +
91
+ `cat "$d/SKILL.md"; ` +
92
+ `printf '\\0'; ` +
93
+ `done`;
94
+
95
+ const raw = await dockerExecShellNoFail(containerName, script, {
96
+ timeoutMs: 15000,
97
+ maxBuffer: 8 * 1024 * 1024,
98
+ });
99
+
100
+ const out = new Map();
101
+ if (!raw) return out;
102
+
103
+ const parts = raw.split('\0');
104
+ // Pairs of name, content — a trailing empty element from the final NUL is fine.
105
+ for (let i = 0; i + 1 < parts.length; i += 2) {
106
+ const name = parts[i].trim();
107
+ if (name) out.set(name, parts[i + 1]);
102
108
  }
109
+ return out;
103
110
  }
104
111
 
105
112
  /**
@@ -136,66 +143,51 @@ export async function GET(request) {
136
143
  }
137
144
  } catch {}
138
145
 
139
- // 2. Agent container skills (workspace-local)
140
- const agents = discoverAgentContainers();
141
- for (const agent of agents) {
142
- const dirs = readContainerSkillDirs(agent.containerName);
143
- for (const dir of dirs) {
144
- const content = readContainerSkillMd(agent.containerName, dir);
145
- if (content) {
146
- const { description, version } = parseSkillContent(content);
147
- skills.push({
148
- name: dir,
149
- type: 'workspace',
150
- agentId: agent.agentId,
151
- containerName: agent.containerName,
152
- path: `/root/.openclaw/workspace/skills/${dir}`,
153
- skillMdPath: `/root/.openclaw/workspace/skills/${dir}/SKILL.md`,
154
- hasSkillMd: true,
155
- description,
156
- version,
157
- });
158
- }
146
+ // 2. Agent container skills (workspace-local) — one bulk read per agent,
147
+ // agents queried concurrently.
148
+ const agents = await discoverAgentContainers();
149
+ const perAgent = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, (agent) =>
150
+ readSkillsBulk(agent.containerName, WORKSPACE_SKILLS_DIR),
151
+ );
152
+
153
+ for (const [i, found] of perAgent.entries()) {
154
+ const agent = agents[i];
155
+ for (const [name, content] of found) {
156
+ const { description, version } = parseSkillContent(content);
157
+ skills.push({
158
+ name,
159
+ type: 'workspace',
160
+ agentId: agent.agentId,
161
+ containerName: agent.containerName,
162
+ path: `${WORKSPACE_SKILLS_DIR}/${name}`,
163
+ skillMdPath: `${WORKSPACE_SKILLS_DIR}/${name}/SKILL.md`,
164
+ hasSkillMd: true,
165
+ description,
166
+ version,
167
+ });
159
168
  }
160
169
  }
161
170
 
162
- // 3. Bundled skills (read-only) — discover from any agent container
171
+ // 3. Bundled skills (read-only) — identical in every agent, so the first
172
+ // container that answers is enough. One bulk read instead of 52 `cat`s.
163
173
  try {
164
- let bundledDirNames = [];
165
- let bundledSourceContainer = null;
166
-
167
- if (agents.length > 0) {
168
- for (const agent of agents) {
169
- try {
170
- const raw = execFileSync('docker', [
171
- 'exec', agent.containerName, 'ls', '-1', CONTAINER_BUNDLED_SKILLS_DIR
172
- ], { encoding: 'utf8', timeout: 5000 }).trim();
173
- if (raw) {
174
- bundledDirNames = raw.split('\n').map(l => l.trim()).filter(Boolean);
175
- bundledSourceContainer = agent.containerName;
176
- break;
177
- }
178
- } catch { /* try next container */ }
179
- }
180
- }
174
+ for (const agent of agents) {
175
+ const found = await readSkillsBulk(agent.containerName, CONTAINER_BUNDLED_SKILLS_DIR);
176
+ if (found.size === 0) continue; // container not ready — try the next one
181
177
 
182
- for (const name of bundledDirNames) {
183
- try {
184
- const skillMdPath = `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`;
185
- const content = execFileSync('docker', [
186
- 'exec', bundledSourceContainer, 'cat', skillMdPath
187
- ], { encoding: 'utf8', timeout: 5000 });
178
+ for (const [name, content] of found) {
188
179
  const { description, version } = parseSkillContent(content);
189
180
  skills.push({
190
181
  name,
191
182
  type: 'bundled',
192
183
  path: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}`,
193
- skillMdPath,
184
+ skillMdPath: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`,
194
185
  hasSkillMd: true,
195
186
  description,
196
187
  version,
197
188
  });
198
- } catch { /* skip this skill */ }
189
+ }
190
+ break;
199
191
  }
200
192
  } catch {}
201
193
 
@@ -3,10 +3,25 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { execSync, execFileSync } from 'child_process';
5
5
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+ import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
6
7
 
7
- const SHARED_SKILLS_DIR = '/docker/shared-skills';
8
8
  const BUNDLED_SKILLS_DIR = '/usr/lib/node_modules/openclaw/skills';
9
9
 
10
+ /**
11
+ * Pick an HTTP status from a caught error.
12
+ *
13
+ * Only the deliberate `Object.assign(new Error(…), { status: 404 })` throws in
14
+ * this file carry a real HTTP status. A failing `docker exec` also sets
15
+ * `.status`, but to the process exit code — usually 1. Passing that straight
16
+ * to NextResponse makes undici throw `RangeError: init["status"] must be in
17
+ * the range of 200 to 599`, so the caller got an opaque 500 with no body
18
+ * instead of the intended error. Anything outside the valid range falls back.
19
+ */
20
+ function httpStatus(err, fallback) {
21
+ const s = err?.status;
22
+ return Number.isInteger(s) && s >= 200 && s <= 599 ? s : fallback;
23
+ }
24
+
10
25
  /**
11
26
  * Read SKILL.md content from various sources:
12
27
  * - Host path (shared / bundled)
@@ -96,8 +111,7 @@ export async function POST(request) {
96
111
  return NextResponse.json({ ok: true, saved: isContainer ? skillPath : absPath });
97
112
  } catch (err) {
98
113
  const msg = err instanceof Error ? err.message : 'Internal error';
99
- const status = err.status || 500;
100
- return NextResponse.json({ error: msg }, { status });
114
+ return NextResponse.json({ error: msg }, { status: httpStatus(err, 500) });
101
115
  }
102
116
  }
103
117
 
@@ -136,7 +150,6 @@ export async function GET(request) {
136
150
  return NextResponse.json({ content });
137
151
  } catch (err) {
138
152
  const msg = err instanceof Error ? err.message : 'Internal error';
139
- const status = err.status || 404;
140
- return NextResponse.json({ error: msg }, { status });
153
+ return NextResponse.json({ error: msg }, { status: httpStatus(err, 404) });
141
154
  }
142
155
  }
@@ -1,5 +1,4 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { jwtVerify } from 'jose';
3
2
  import { execSync } from 'child_process';
4
3
  import { readFileSync } from 'fs';
5
4
  import { join } from 'path';
@@ -33,7 +32,8 @@ const CACHE_TTL_MS = 15 * 60 * 1000;
33
32
  * GET /api/update-check
34
33
  */
35
34
  export async function GET(request: Request): Promise<NextResponse> {
36
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
35
+ const denied = await requireAuthJWT(request);
36
+ if (denied) return denied;
37
37
  const url = new URL(request.url);
38
38
  const check = url.searchParams.get('check') === '1';
39
39
 
@@ -71,23 +71,6 @@ export async function GET(request: Request): Promise<NextResponse> {
71
71
  return NextResponse.json({ installed, latest, updateAvailable, package: getPackageInfo().name });
72
72
  }
73
73
 
74
- async function verifyAuth(request: Request): Promise<boolean> {
75
- const raw = request.headers.get('cookie') || '';
76
- const match = raw.split(';').map(c => c.trim()).find(c => c.startsWith('rev4a_token='));
77
- if (!match) return false;
78
-
79
- const cookieToken = decodeURIComponent(match.split('=')[1]);
80
- try {
81
- const secret = new TextEncoder().encode(
82
- process.env.REV4A_JWT_SECRET || (() => { throw new Error('REV4A_JWT_SECRET not set'); })(),
83
- );
84
- await jwtVerify(cookieToken, secret);
85
- return true;
86
- } catch {
87
- return false;
88
- }
89
- }
90
-
91
74
  /**
92
75
  * POST /api/update-check
93
76
  *
@@ -97,10 +80,8 @@ async function verifyAuth(request: Request): Promise<boolean> {
97
80
  * with the newly installed binary. No systemd required.
98
81
  */
99
82
  export async function POST(request: Request): Promise<NextResponse> {
100
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
101
- if (!(await verifyAuth(request))) {
102
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
103
- }
83
+ const denied = await requireAuthJWT(request);
84
+ if (denied) return denied;
104
85
 
105
86
  // Spawn rev4a update in background, then write restart flag directly
106
87
  // from this process so rev4a serve detects it and restarts.
@@ -4,16 +4,20 @@
4
4
  * Sets wizard as complete in a persistent file (cross-browser).
5
5
  * Also sets the httpOnly cookie for backwards compatibility.
6
6
  */
7
- import { NextResponse } from 'next/server';
7
+ import { NextResponse, type NextRequest } from 'next/server';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { REV4A_DATA } from '@/lib/rev4a-paths';
11
+ import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
11
12
 
12
13
  export const dynamic = 'force-dynamic';
13
14
 
14
- const WIZARD_FILE = REV4A_DATA + '/data/wizard.json';
15
+ const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
16
+
17
+ export async function POST(request: NextRequest): Promise<NextResponse> {
18
+ const denied = await requireAuthIfConfigured(request);
19
+ if (denied) return denied;
15
20
 
16
- export function POST(): NextResponse {
17
21
  // Write persistent marker
18
22
  const dir = path.dirname(WIZARD_FILE);
19
23
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -4,15 +4,20 @@
4
4
  * Clears the wizard marker so the wizard runs again.
5
5
  * Removes both the persistent file and the cookie.
6
6
  */
7
- import { NextResponse } from 'next/server';
7
+ import { NextResponse, type NextRequest } from 'next/server';
8
8
  import * as fs from 'fs';
9
+ import * as path from 'path';
9
10
  import { REV4A_DATA } from '@/lib/rev4a-paths';
11
+ import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
10
12
 
11
13
  export const dynamic = 'force-dynamic';
12
14
 
13
- const WIZARD_FILE = REV4A_DATA + '/data/wizard.json';
15
+ const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
16
+
17
+ export async function POST(request: NextRequest): Promise<NextResponse> {
18
+ const denied = await requireAuthIfConfigured(request);
19
+ if (denied) return denied;
14
20
 
15
- export function POST(): NextResponse {
16
21
  // Remove persistent marker
17
22
  try { fs.unlinkSync(WIZARD_FILE); } catch { /* ignore */ }
18
23
 
@@ -4,13 +4,15 @@
4
4
  * Returns whether the wizard is complete.
5
5
  * Checks a persistent file so it survives browser changes and cache clears.
6
6
  */
7
- import { NextResponse } from 'next/server';
7
+ import { NextResponse, type NextRequest } from 'next/server';
8
8
  import * as fs from 'fs';
9
+ import * as path from 'path';
9
10
  import { REV4A_DATA } from '@/lib/rev4a-paths';
11
+ import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
10
12
 
11
13
  export const dynamic = 'force-dynamic';
12
14
 
13
- const WIZARD_FILE = REV4A_DATA + '/data/wizard.json';
15
+ const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
14
16
 
15
17
  function readWizardFile(): boolean {
16
18
  try {
@@ -22,7 +24,8 @@ function readWizardFile(): boolean {
22
24
  }
23
25
  }
24
26
 
25
- export function GET(): NextResponse {
26
- const done = readWizardFile();
27
- return NextResponse.json({ done });
27
+ export async function GET(request: NextRequest): Promise<NextResponse> {
28
+ const denied = await requireAuthIfConfigured(request);
29
+ if (denied) return denied;
30
+ return NextResponse.json({ done: readWizardFile() });
28
31
  }
@@ -7,17 +7,38 @@ import { useRouter, usePathname } from 'next/navigation';
7
7
  const authCache = { valid: false, checked: false, ts: 0 };
8
8
  const AUTH_CACHE_TTL = 30_000; // 30s before re-check
9
9
 
10
+ function isCacheValid(): boolean {
11
+ return authCache.checked && authCache.valid && (Date.now() - authCache.ts) < AUTH_CACHE_TTL;
12
+ }
13
+
10
14
  export default function AuthGuard({ children }: { children: React.ReactNode }) {
11
15
  const router = useRouter();
12
16
  const pathname = usePathname();
13
17
  const [redirecting, setRedirecting] = useState(false);
14
18
  const checkingRef = useRef(false);
15
19
 
20
+ // Which pathname the last successful check applies to. Kept per-path so a
21
+ // client-side navigation to a different protected route can't inherit the
22
+ // previous route's "verified" state and flash its content for a frame.
23
+ const [verifiedPath, setVerifiedPath] = useState<string | null>(null);
24
+
25
+ const isPublicRoute = pathname === '/setup' || pathname === '/login';
26
+ // Evaluated fresh on every render (not held in state), so an expired cache
27
+ // closes the gate immediately rather than one paint late.
28
+ const ready = isPublicRoute || isCacheValid() || verifiedPath === pathname;
29
+
16
30
  useEffect(() => {
31
+ // A redirect that has landed is finished: clear the flag on every
32
+ // navigation, otherwise the render gate below stays closed forever and
33
+ // the destination page renders blank. (AppShell currently unmounts this
34
+ // component for /login, /setup and /wizard, which used to hide the bug —
35
+ // don't depend on that staying true.)
36
+ setRedirecting(false);
37
+
17
38
  // /setup: always accessible
18
39
  if (pathname === '/setup') return;
19
40
 
20
- // /login: public, check if already authenticated
41
+ // /login: public, but bounce to /setup if no password exists yet
21
42
  if (pathname === '/login') {
22
43
  fetch('/api/auth/status')
23
44
  .then((r) => r.json())
@@ -31,18 +52,16 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
31
52
  // Already checking — prevent duplicate
32
53
  if (checkingRef.current) return;
33
54
 
34
- // Cache hit — skip check
35
- if (authCache.checked && authCache.valid && (Date.now() - authCache.ts) < AUTH_CACHE_TTL) {
36
- return; // Still valid, no re-check
37
- }
55
+ // Cache hit — content is already known-good, `ready` above covers it
56
+ if (isCacheValid()) return;
38
57
 
39
58
  checkingRef.current = true;
40
59
 
41
- // Protected routes — check auth in background
60
+ // Protected route with no valid cache — verify before revealing content
42
61
  fetch('/api/auth/check')
43
62
  .then(async (r) => {
44
63
  if (!r.ok) {
45
- // Not authenticated — redirect
64
+ // Not authenticated — redirect, keep content hidden
46
65
  authCache.valid = false;
47
66
  authCache.checked = true;
48
67
  const status = await fetch('/api/auth/status').then((r2) => r2.json());
@@ -55,6 +74,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
55
74
  authCache.valid = true;
56
75
  authCache.checked = true;
57
76
  authCache.ts = Date.now();
77
+ setVerifiedPath(pathname);
58
78
 
59
79
  if (pathname === '/wizard') return;
60
80
 
@@ -86,6 +106,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
86
106
  });
87
107
  }, [pathname, router]);
88
108
 
89
- // NEVER hide children — always render, auth check happens in background
109
+ if (redirecting) return null;
110
+ if (!ready) return null; // hold content until this route's check resolves
90
111
  return <>{children}</>;
91
112
  }
@@ -95,6 +95,77 @@ export function CodeSkeleton({ lines = 12 }: { lines?: number }) {
95
95
  );
96
96
  }
97
97
 
98
+ /**
99
+ * Cron job rows — mirrors the real row in the scheduled-jobs column so the
100
+ * layout doesn't jump when the data lands: same 12px/14px padding, same divider,
101
+ * same title/description/badge header, schedule line, and NEXT RUN / LAST RUN
102
+ * grid. Widths vary per row so it reads as a list rather than a stack of bars.
103
+ */
104
+ export function CronJobsSkeleton({ rows = 5 }: { rows?: number }) {
105
+ const shapes = [
106
+ { title: '58%', desc: '82%', badge: 54, expr: '34%' },
107
+ { title: '42%', desc: null, badge: 40, expr: '28%' },
108
+ { title: '66%', desc: '71%', badge: 62, expr: '40%' },
109
+ { title: '50%', desc: null, badge: 46, expr: '31%' },
110
+ { title: '61%', desc: '64%', badge: 58, expr: '36%' },
111
+ { title: '45%', desc: null, badge: 50, expr: '26%' },
112
+ ];
113
+
114
+ return (
115
+ <div aria-hidden="true">
116
+ {shapes.slice(0, rows).map((s, i) => (
117
+ <div key={i} style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
118
+ {/* Header: title + optional description on the left, agent pill right */}
119
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, gap: 8 }}>
120
+ <div style={{ flex: 1, minWidth: 0 }}>
121
+ <Skeleton width={s.title} height={12} accent />
122
+ {s.desc && <Skeleton width={s.desc} height={9} style={{ marginTop: 4 }} />}
123
+ </div>
124
+ <Skeleton width={s.badge} height={13} style={{ flexShrink: 0 }} />
125
+ </div>
126
+
127
+ {/* Schedule line: label + cron expression */}
128
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
129
+ <Skeleton width={46} height={9} />
130
+ <Skeleton width={s.expr} height={10} />
131
+ </div>
132
+
133
+ {/* NEXT RUN / LAST RUN grid */}
134
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px' }}>
135
+ {[0, 1].map((c) => (
136
+ <div key={c}>
137
+ <Skeleton width={52} height={8} style={{ marginBottom: 3 }} />
138
+ <Skeleton width="72%" height={10} />
139
+ </div>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Cron run-history entries — one timestamp line plus a status/duration row,
150
+ * matching the entries the runs column renders once a job is selected.
151
+ */
152
+ export function CronRunsSkeleton({ rows = 6 }: { rows?: number }) {
153
+ const widths = ['64%', '48%', '71%', '55%', '60%', '44%'];
154
+ return (
155
+ <div aria-hidden="true">
156
+ {widths.slice(0, rows).map((w, i) => (
157
+ <div key={i} style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
158
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 5 }}>
159
+ <Skeleton width={w} height={10} />
160
+ <Skeleton width={38} height={11} style={{ flexShrink: 0 }} />
161
+ </div>
162
+ <Skeleton width="30%" height={9} />
163
+ </div>
164
+ ))}
165
+ </div>
166
+ );
167
+ }
168
+
98
169
  /* ─── Legacy aliases (used by existing components) ────────────────────────── */
99
170
  /** Stack of horizontal skeleton lines */
100
171
  export function SkeletonLines({ count = 3, width }: { count?: number; width?: string | number }) {