@flame0510/project-aether 1.1.15 → 1.2.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.
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { getProfile, markSynced } from '@/lib/credentials/vault';
2
+ import { getProfile, markSynced, markDesynced } from '@/lib/credentials/vault';
3
3
  import { syncProfileToAgents, cleanAgentProviders } from '@/lib/credentials/delivery';
4
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
5
 
@@ -48,8 +48,8 @@ export async function DELETE(request: Request,
48
48
  return NextResponse.json({ error: 'container is required' }, { status: 400 });
49
49
  }
50
50
 
51
- cleanAgentProviders(container, profile.providerId);
52
- markSynced(id, 'active');
51
+ await cleanAgentProviders(container, profile.providerId);
52
+ markDesynced(id);
53
53
 
54
54
  return NextResponse.json({ ok: true });
55
55
  } catch (e: unknown) {
@@ -2,17 +2,22 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { listProfiles, getSecret } from '@/lib/credentials/vault';
3
3
  import {
4
4
  listAgentContainers,
5
- execInContainer,
6
- cliInstalled,
5
+ probeContainer,
7
6
  providerTokenHash,
8
7
  profileTokenHash,
9
- detectTrello,
10
- detectNotion,
8
+ vercelCurrentTeam,
9
+ supabaseProjectRef,
10
+ TOKEN_FIELD,
11
+ type ContainerProbe,
11
12
  } from '@/lib/credentials/detect';
13
+ import { mapWithConcurrency } from '@/lib/docker-exec';
12
14
  import { requireAuthJWT } from '@/lib/rev4a-auth';
13
15
 
14
16
  export const dynamic = 'force-dynamic';
15
17
 
18
+ /** Containers probed at once. Each is a single `docker exec`. */
19
+ const AGENT_QUERY_CONCURRENCY = 6;
20
+
16
21
  interface DetectedStatus {
17
22
  container: string;
18
23
  agentId: string;
@@ -33,6 +38,12 @@ interface DetectedStatus {
33
38
  notionLoggedIn: boolean;
34
39
  toolsMdHasMarkers: boolean;
35
40
  matchedProfiles: { profileId: string; providerId: string; label: string }[];
41
+ /**
42
+ * Providers with a token present in the container, whether or not it belongs
43
+ * to a stored profile. A provider listed here but absent from
44
+ * `matchedProfiles` holds a credential nobody in the vault accounts for.
45
+ */
46
+ installedProviders: string[];
36
47
  }
37
48
 
38
49
  interface DetectionError {
@@ -41,125 +52,106 @@ interface DetectionError {
41
52
  error: string;
42
53
  }
43
54
 
44
- function detectSupabase(container: string): { loggedIn: boolean; linked: boolean; projectRef: string } {
45
- if (!cliInstalled(container, 'supabase')) {
46
- return { loggedIn: false, linked: false, projectRef: '' };
47
- }
48
-
49
- // Verify login by calling the Supabase API (analogous to gh auth status / vercel whoami)
50
- const raw = execInContainer(
51
- container,
52
- 'supabase projects list --output json 2>&1',
53
- (r) => r.trim(),
54
- '',
55
- );
56
-
57
- // Unauthorized → not logged in
58
- if (!raw || raw.includes('Unauthorized')) {
59
- return { loggedIn: false, linked: false, projectRef: '' };
60
- }
61
-
62
- // Check if a linked project exists in the workspace
63
- let linked = false;
64
- let projectRef = '';
65
- const linkedRef = execInContainer(
66
- container,
67
- 'cat /data/supabase/config.toml 2>/dev/null | grep project_id | head -1 | sed "s/.*= *\"//;s/\".*//"',
68
- (r) => r.trim(),
69
- '',
70
- );
71
- if (linkedRef && linkedRef.length > 0) {
72
- linked = true;
73
- projectRef = linkedRef;
74
- }
75
-
76
- return { loggedIn: true, linked, projectRef };
77
- }
78
-
79
- function detectGithub(container: string): { loggedIn: boolean; user: string } {
80
- if (!cliInstalled(container, 'gh')) {
81
- return { loggedIn: false, user: '' };
82
- }
83
-
84
- const raw = execInContainer(
85
- container,
86
- 'gh auth status 2>&1',
87
- (r) => r,
88
- '',
89
- );
90
- if (!raw || raw.includes('not logged into any GitHub hosts')) {
91
- return { loggedIn: false, user: '' };
92
- }
93
- const match = raw.match(/Logged in to github\.com as (\S+)/);
94
- return { loggedIn: !!match, user: match?.[1] ?? '' };
55
+ /** Last non-empty line of `vercel whoami` — the rest is header noise. */
56
+ function extractUsername(raw: string): string {
57
+ const lines = raw.split('\n').filter((l) => l.trim());
58
+ return lines[lines.length - 1]?.trim() || raw.trim();
95
59
  }
96
60
 
97
- function detectToolsMd(container: string): boolean {
98
- const raw = execInContainer(
99
- container,
100
- `grep -c 'REV4A:INTEGRATIONS:START' /root/.openclaw/workspace/TOOLS.md 2>/dev/null || echo 0`,
101
- (r) => r.trim(),
102
- '0',
103
- );
104
- return raw === '1' || parseInt(raw, 10) > 0;
61
+ /**
62
+ * Stored profiles with their token hashes, computed once per request.
63
+ *
64
+ * This used to run inside the per-container loop: `listProfiles()` plus one
65
+ * `getSecret()` per profile, each opening its own SQLite connection, repeated
66
+ * for every agent. Nothing in it depends on the container, so it is hoisted.
67
+ */
68
+ interface HashedProfile {
69
+ profileId: string;
70
+ providerId: string;
71
+ label: string;
72
+ tokenHash: string;
105
73
  }
106
74
 
107
- function detectVercel(container: string): { loggedIn: boolean; user: string; team: string } {
108
- if (!cliInstalled(container, 'vercel')) {
109
- return { loggedIn: false, user: '', team: '' };
110
- }
111
-
112
- const whoami = execInContainer(
113
- container,
114
- 'vercel whoami 2>&1 || true',
115
- (r) => r.trim(),
116
- '',
117
- );
118
- if (whoami && !whoami.includes('Error:') && !whoami.includes('Not authorized')) {
119
- const teamRaw = execInContainer(
120
- container,
121
- "cat /root/.local/share/com.vercel.cli/config.json 2>/dev/null | grep 'currentTeam' | head -1 | sed 's/.*: *\"//;s/\".*//'",
122
- (r) => r.trim(),
123
- '',
124
- );
125
- return { loggedIn: true, user: extractUsername(whoami), team: teamRaw };
126
- }
127
-
128
- return { loggedIn: false, user: '', team: '' };
75
+ function loadHashedProfiles(): HashedProfile[] {
76
+ return listProfiles()
77
+ .map((p) => ({
78
+ profileId: p.id,
79
+ providerId: p.providerId,
80
+ label: p.label,
81
+ tokenHash: profileTokenHash(getSecret(p.id), p.providerId),
82
+ }))
83
+ .filter((p) => p.tokenHash);
129
84
  }
130
85
 
131
86
  /**
132
- * Extract just the username from 'vercel whoami' output (strip header lines).
87
+ * Hash of whatever token each provider currently has installed in the container.
88
+ *
89
+ * Computed for every provider, not only those with a stored profile. The probe
90
+ * has already fetched all of these files in its single exec, so the extra work
91
+ * is a handful of SHA-256 over short strings — and restricting it would hide the
92
+ * case that matters most: a token present in the container that matches nothing
93
+ * in the vault, which is what a credential edited after syncing looks like.
133
94
  */
134
- function extractUsername(raw: string): string {
135
- const lines = raw.split('\n').filter(Boolean);
136
- // Return the last non-empty line (the actual username)
137
- return lines[lines.length - 1]?.trim() || raw;
95
+ function installedHashes(probe: ContainerProbe): Map<string, string> {
96
+ const out = new Map<string, string>();
97
+ for (const providerId of Object.keys(TOKEN_FIELD)) {
98
+ const h = providerTokenHash(probe, providerId);
99
+ if (h) out.set(providerId, h);
100
+ }
101
+ return out;
138
102
  }
139
103
 
140
- /**
141
- * Match detected state to stored credential profiles by comparing token hashes.
142
- * `hashes` maps a providerId to the hash of the token currently installed in the
143
- * container. A profile matches when its own token hashes to the same value.
144
- * Providers whose token cannot be read yield '' and therefore never match.
145
- */
146
- function matchProfiles(
147
- hashes: Record<string, string>,
148
- ): { profileId: string; providerId: string; label: string }[] {
149
- const profiles = listProfiles();
150
- const matched: { profileId: string; providerId: string; label: string }[] = [];
151
-
152
- for (const profile of profiles) {
153
- const installedHash = hashes[profile.providerId];
154
- if (!installedHash) continue;
155
-
156
- const storedHash = profileTokenHash(getSecret(profile.id), profile.providerId);
157
- if (storedHash && storedHash === installedHash) {
158
- matched.push({ profileId: profile.id, providerId: profile.providerId, label: profile.label });
159
- }
160
- }
104
+ function matchProfiles(installed: Map<string, string>, profiles: HashedProfile[]) {
105
+ return profiles
106
+ .filter((p) => installed.get(p.providerId) === p.tokenHash)
107
+ .map(({ profileId, providerId, label }) => ({ profileId, providerId, label }));
108
+ }
161
109
 
162
- return matched;
110
+ function shape(container: string, agentId: string, probe: ContainerProbe, profiles: HashedProfile[]): DetectedStatus {
111
+ const installed = installedHashes(probe);
112
+ // `gh` changed the wording: 2.97 prints "…github.com account <user>", older
113
+ // builds print "…as <user>". The image installs it unpinned from apt, so both
114
+ // are accepted rather than tracking whichever one ships today.
115
+ const ghMatch = /Logged in to github\.com (?:as|account) (\S+)/.exec(probe.ghStatus);
116
+ const githubLoggedIn =
117
+ probe.cli.gh && !probe.ghStatus.includes('not logged into any GitHub hosts') && !!ghMatch;
118
+
119
+ const whoami = probe.vercelWhoami.trim();
120
+ const vercelLoggedIn =
121
+ probe.cli.vercel && !!whoami && !whoami.includes('Error:') && !whoami.includes('Not authorized');
122
+
123
+ const projects = probe.supabaseProjects.trim();
124
+ // The exit status is the signal, not the wording. Matching on 'Unauthorized'
125
+ // recognised only the one failure whose text happened to say so; a missing
126
+ // token and an unreachable network both printed something else and passed.
127
+ const supabaseLoggedIn = probe.cli.supabase && probe.supabaseRc === 0 && !!projects;
128
+ const projectRef = supabaseLoggedIn ? supabaseProjectRef(probe) : '';
129
+
130
+ return {
131
+ container,
132
+ agentId,
133
+ displayName: probe.agentName || undefined,
134
+ githubLoggedIn,
135
+ githubUser: ghMatch?.[1] ?? '',
136
+ githubCliInstalled: probe.cli.gh,
137
+ vercelLoggedIn,
138
+ vercelUser: vercelLoggedIn ? extractUsername(probe.vercelWhoami) : '',
139
+ vercelTeam: vercelLoggedIn ? vercelCurrentTeam(probe) : '',
140
+ vercelCliInstalled: probe.cli.vercel,
141
+ supabaseLoggedIn,
142
+ supabaseLinked: !!projectRef,
143
+ supabaseProjectRef: projectRef,
144
+ supabaseCliInstalled: probe.cli.supabase,
145
+ // Presence of the config file is the login signal for both — neither ships a
146
+ // status command. An empty file now counts as absent, where `test -f` used
147
+ // to accept it.
148
+ trelloLoggedIn: !!probe.files.trelloConfig.trim(),
149
+ trelloCliInstalled: probe.cli.trello,
150
+ notionLoggedIn: !!probe.files.notionConfig.trim(),
151
+ toolsMdHasMarkers: probe.toolsMdMarkers > 0,
152
+ matchedProfiles: matchProfiles(installed, profiles),
153
+ installedProviders: [...installed.keys()],
154
+ };
163
155
  }
164
156
 
165
157
  export async function GET(request: NextRequest): Promise<NextResponse> {
@@ -167,8 +159,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
167
159
  try {
168
160
  const url = new URL(request.url);
169
161
  const filterAgent = url.searchParams.get('agent');
170
- let containers = listAgentContainers();
171
162
 
163
+ let containers = await listAgentContainers();
172
164
  if (filterAgent) {
173
165
  containers = containers.filter((c) => c.name === filterAgent);
174
166
  if (containers.length === 0) {
@@ -176,70 +168,28 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
176
168
  }
177
169
  }
178
170
 
179
- const results: (DetectedStatus | DetectionError)[] = [];
180
-
181
- for (const { name, agentId } of containers) {
182
- try {
183
- const { loggedIn: githubLoggedIn, user: githubUser } = detectGithub(name);
184
- const { loggedIn: trelloLoggedIn } = detectTrello(name);
185
- const { loggedIn: notionLoggedIn } = detectNotion(name);
186
- const { loggedIn: vercelLoggedIn, user: vercelUser, team: vercelTeam } = detectVercel(name);
187
- const { loggedIn: supabaseLoggedIn, linked: supabaseLinked, projectRef: supabaseProjectRef } = detectSupabase(name);
188
-
189
- const githubCliInstalled = cliInstalled(name, 'gh');
190
- const vercelCliInstalled = cliInstalled(name, 'vercel');
191
- const supabaseCliInstalled = cliInstalled(name, 'supabase');
192
- const trelloCliInstalled = cliInstalled(name, 'trello');
193
- const toolsMdHasMarkers = detectToolsMd(name);
194
-
195
- // Read AGENT_NAME env for display name (may differ from container name)
196
- const displayName = execInContainer(
197
- name,
198
- 'printenv AGENT_NAME',
199
- (r) => r.trim(),
200
- '',
201
- ) || undefined;
202
-
203
- // Precise profile matching by token hash — one source of truth for all providers.
204
- const matchedProfiles = matchProfiles({
205
- 'github-pat': providerTokenHash(name, 'github-pat'),
206
- vercel: providerTokenHash(name, 'vercel'),
207
- supabase: providerTokenHash(name, 'supabase'),
208
- trello: providerTokenHash(name, 'trello'),
209
- notion: providerTokenHash(name, 'notion'),
210
- });
211
-
212
- results.push({
213
- container: name,
214
- agentId,
215
- displayName,
216
- githubLoggedIn,
217
- githubUser,
218
- githubCliInstalled,
219
- vercelLoggedIn,
220
- vercelUser,
221
- vercelTeam,
222
- vercelCliInstalled,
223
- supabaseLoggedIn,
224
- supabaseLinked,
225
- supabaseProjectRef,
226
- supabaseCliInstalled,
227
- trelloLoggedIn,
228
- trelloCliInstalled,
229
- notionLoggedIn,
230
- toolsMdHasMarkers,
231
- matchedProfiles,
232
- });
233
- } catch (e: unknown) {
234
- results.push({
235
- container: name,
236
- agentId,
237
- error: (e as Error).message,
238
- });
239
- }
240
- }
171
+ const profiles = loadHashedProfiles();
172
+
173
+ // The callback swallows its own failures: mapWithConcurrency is fail-fast,
174
+ // so letting one unreachable container reject would discard the results
175
+ // already computed for every other agent.
176
+ const agents = await mapWithConcurrency<{ name: string; agentId: string }, DetectedStatus | DetectionError>(
177
+ containers,
178
+ AGENT_QUERY_CONCURRENCY,
179
+ async ({ name, agentId }) => {
180
+ try {
181
+ const probe = await probeContainer(name);
182
+ if (!probe.ok) {
183
+ return { container: name, agentId, error: 'Container did not respond' };
184
+ }
185
+ return shape(name, agentId, probe, profiles);
186
+ } catch (e: unknown) {
187
+ return { container: name, agentId, error: (e as Error).message };
188
+ }
189
+ },
190
+ );
241
191
 
242
- return NextResponse.json({ agents: results });
192
+ return NextResponse.json({ agents });
243
193
  } catch (e: unknown) {
244
194
  return NextResponse.json({ error: (e as Error).message }, { status: 500 });
245
195
  }
@@ -7,6 +7,9 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
7
7
  export const dynamic = 'force-dynamic';
8
8
 
9
9
  export async function GET(request: NextRequest): Promise<NextResponse> {
10
+ // requireAuthJWT was imported for the POST below and never applied here,
11
+ // leaving this the only unauthenticated route in the credentials feature.
12
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
10
13
  try {
11
14
  const profiles = listProfiles();
12
15
  return NextResponse.json({ profiles, providers: CREDENTIAL_PROVIDERS });
@@ -202,3 +202,135 @@ export function CardRowSkeleton({ count = 3, height = 56 }: { count?: number; he
202
202
  </div>
203
203
  );
204
204
  }
205
+
206
+ /**
207
+ * Credential provider cards.
208
+ *
209
+ * The credentials page renders one card per entry in CREDENTIAL_PROVIDERS,
210
+ * which is a static array of five — so the card count here is known, not
211
+ * guessed, and the grid will not reflow when the data lands.
212
+ *
213
+ * Each shape mirrors the real text it stands in for: the label and category
214
+ * widths track the actual provider names ("Cloud" is short, "Project
215
+ * Management" is not), and the description line count follows how long each
216
+ * provider's blurb actually is. That is what keeps the shimmer from jumping
217
+ * when it is replaced.
218
+ *
219
+ * `configuredCards` is the one genuine unknown: whether a provider has stored
220
+ * profiles is only known once /api/credentials answers. Cards with a profile
221
+ * are markedly taller, so rendering none would guarantee a jump downward for
222
+ * anyone who has set some up. Two of five is the middle of the realistic range
223
+ * and keeps the error small in both directions.
224
+ */
225
+ export function CredentialCardsSkeleton({ configuredCards = 2 }: { configuredCards?: number }) {
226
+ // Ordered as CREDENTIAL_PROVIDERS: GitHub, Trello, Vercel, Supabase, Notion.
227
+ const cards = [
228
+ { label: 46, cat: 84, pill: 64, desc: ['100%', '58%'], delivery: '56%' },
229
+ { label: 44, cat: 98, pill: 54, desc: ['92%'], delivery: '82%' },
230
+ { label: 44, cat: 34, pill: 64, desc: ['100%', '64%'], delivery: '90%' },
231
+ { label: 58, cat: 50, pill: 54, desc: ['100%', '86%'], delivery: '78%' },
232
+ { label: 44, cat: 98, pill: 54, desc: ['100%', '92%'], delivery: '72%' },
233
+ ];
234
+
235
+ return (
236
+ <div
237
+ aria-hidden="true"
238
+ style={{
239
+ display: 'grid',
240
+ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
241
+ gap: 'clamp(12px, 3vw, 16px)',
242
+ }}
243
+ >
244
+ {cards.map((c, i) => (
245
+ <div
246
+ key={i}
247
+ style={{
248
+ background: 'var(--bg2)',
249
+ border: '1px solid var(--border)',
250
+ display: 'flex',
251
+ flexDirection: 'column',
252
+ }}
253
+ >
254
+ {/* Header: provider badge, name over category, status pill */}
255
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
256
+ <Skeleton width={28} height={28} accent style={{ flexShrink: 0 }} />
257
+ <div style={{ flex: 1, minWidth: 0 }}>
258
+ <Skeleton width={c.label} height={12} />
259
+ <Skeleton width={c.cat} height={9} style={{ marginTop: 4 }} />
260
+ </div>
261
+ <Skeleton width={c.pill} height={16} style={{ flexShrink: 0 }} />
262
+ </div>
263
+
264
+ {/* Description */}
265
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
266
+ {c.desc.map((w, j) => (
267
+ <Skeleton key={j} width={w} height={9} style={{ marginTop: j === 0 ? 0 : 5 }} />
268
+ ))}
269
+ </div>
270
+
271
+ {/* Delivery method — one mono line */}
272
+ <div style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
273
+ <Skeleton width={c.delivery} height={9} />
274
+ </div>
275
+
276
+ {/* A stored profile: label, masked secret + status + sync time, actions,
277
+ then the per-agent sync rows underneath. */}
278
+ {i < configuredCards && (
279
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
280
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
281
+ <div style={{ flex: 1, minWidth: 0 }}>
282
+ <Skeleton width="52%" height={11} />
283
+ <div style={{ display: 'flex', gap: 10, marginTop: 5 }}>
284
+ <Skeleton width={76} height={9} />
285
+ <Skeleton width={8} height={9} />
286
+ <Skeleton width={62} height={9} />
287
+ </div>
288
+ </div>
289
+ <Skeleton width={38} height={22} style={{ flexShrink: 0 }} />
290
+ <Skeleton width={22} height={22} style={{ flexShrink: 0 }} />
291
+ </div>
292
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginTop: 9 }}>
293
+ <Skeleton width="74%" height={9} />
294
+ <Skeleton width="66%" height={9} />
295
+ </div>
296
+ </div>
297
+ )}
298
+
299
+ {/* Footer action, pinned to the bottom as in the real card */}
300
+ <div style={{ padding: '10px 14px', marginTop: 'auto' }}>
301
+ <Skeleton width={i < configuredCards ? 92 : 74} height={26} />
302
+ </div>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Per-agent sync rows inside a credential profile.
311
+ *
312
+ * Stands in for the block fed by /api/credentials/detect, which shells into
313
+ * every agent container and takes seconds. The agent list itself comes from that
314
+ * same response, so before it lands the count is unknown — three rows is a
315
+ * placeholder, not a prediction, and the block is deliberately the only thing
316
+ * that waits: the cards, the stored profiles and the Add buttons all come from
317
+ * the cheap endpoint and render immediately.
318
+ *
319
+ * No Sync/De-sync button is drawn here on purpose. Their label depends on
320
+ * whether the credential is installed, which is exactly what is still unknown —
321
+ * a button rendered now would have to guess, and guessing wrong invites a click
322
+ * that does the opposite of what it says.
323
+ */
324
+ export function AgentSyncRowsSkeleton({ rows = 3 }: { rows?: number }) {
325
+ const widths = ['62%', '48%', '55%'];
326
+ return (
327
+ <div aria-hidden="true" style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
328
+ {Array.from({ length: rows }).map((_, i) => (
329
+ <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
330
+ <Skeleton width={8} height={8} variant="circle" style={{ flexShrink: 0 }} />
331
+ <Skeleton width={widths[i % widths.length]} height={9} />
332
+ </div>
333
+ ))}
334
+ </div>
335
+ );
336
+ }