platform-agents.ts376 lines · main
1import { brivenError, newId, NotFoundError, ValidationError } from '@briven/shared';
2
3import { asc, eq } from 'drizzle-orm';
4
5import { getDb } from '../db/client.js';
6import {
7 platformAgents,
8 type PlatformAgent,
9 type PlatformAgentScope,
10} from '../db/schema.js';
11import { decryptTenantSecret, encryptTenantSecret } from './tenant-secret-store.js';
12
13/**
14 * Platform-agent manager — CRUD over the `platform_agents` registry the
15 * admin cockpit uses to wire up named AI agents (anthropic / openai /
16 * ollama / custom).
17 *
18 * Secret handling: unlike mcp-access.ts (where WE mint the key and persist
19 * only a hash), the api key here is INPUT by the admin and must be
20 * recoverable server-side — the /test ping (and future outbound agent
21 * calls) present it to the provider. So it is stored as AES-256-GCM
22 * ciphertext produced by the EXISTING tenant-secret-store primitives
23 * (HKDF-SHA256 derived key + the shared iv||tag||body wire format).
24 * The HKDF salt slot normally carries a project id; here it carries the
25 * AGENT id, so every agent's key is encrypted under its own derived key —
26 * a leak of one agent's plaintext never decrypts another's blob. The
27 * master key is BRIVEN_AUTH_MASTER_KEY (the 'auth' service slot).
28 *
29 * Plaintext is NEVER logged, NEVER stored, and NEVER returned after
30 * creation — reads only ever see `keyPrefix…keySuffix`, mirroring
31 * mcp-access.ts `maskKey`.
32 */
33
34/* ─── masking ────────────────────────────────────────────────────────────── */
35
36/** First chars shown in the masked hint. Suffix is always the last 4. */
37const KEY_PREFIX_CHARS = 4;
38const KEY_SUFFIX_CHARS = 4;
39
40/**
41 * Displayable fragments of an admin-supplied key. The zod boundary enforces
42 * a minimum plaintext length well above prefix+suffix, so the hint can never
43 * reconstruct the key.
44 */
45export function maskAgentKey(plaintext: string): { keyPrefix: string; keySuffix: string } {
46 return {
47 keyPrefix: plaintext.slice(0, KEY_PREFIX_CHARS),
48 keySuffix: plaintext.slice(-KEY_SUFFIX_CHARS),
49 };
50}
51
52export interface MaskedPlatformAgent {
53 id: string;
54 name: string;
55 provider: string;
56 endpoint: string | null;
57 model: string;
58 scope: PlatformAgentScope;
59 enabled: boolean;
60 /** True when an encrypted key is on file. The key itself is never returned. */
61 hasKey: boolean;
62 keyPrefix: string | null;
63 keySuffix: string | null;
64 createdAt: Date;
65 updatedAt: Date;
66}
67
68/** Strip the ciphertext; never return it. Mirrors mcp-access.ts maskKey. */
69export function maskAgent(row: PlatformAgent): MaskedPlatformAgent {
70 return {
71 id: row.id,
72 name: row.name,
73 provider: row.provider,
74 endpoint: row.endpoint,
75 model: row.model,
76 scope: row.scope,
77 enabled: row.enabled,
78 hasKey: row.encryptedApiKey !== null,
79 keyPrefix: row.keyPrefix,
80 keySuffix: row.keySuffix,
81 createdAt: row.createdAt,
82 updatedAt: row.updatedAt,
83 };
84}
85
86/* ─── crypto (reuse of tenant-secret-store, salted per agent) ────────────── */
87
88function encryptAgentKey(agentId: string, plaintext: string): string {
89 // 'auth' selects BRIVEN_AUTH_MASTER_KEY; the projectId slot is the HKDF
90 // salt — the agent id scopes the derived key to this one agent row.
91 return encryptTenantSecret({ service: 'auth', projectId: agentId, plaintext });
92}
93
94function decryptAgentKey(agentId: string, ciphertext: string): string {
95 return decryptTenantSecret({ service: 'auth', projectId: agentId, ciphertext });
96}
97
98/* ─── CRUD ───────────────────────────────────────────────────────────────── */
99
100export interface CreatePlatformAgentInput {
101 name: string;
102 provider: string;
103 endpoint?: string | null;
104 /** Admin-supplied provider key. Optional — local/keyless endpoints exist. */
105 apiKey?: string | null;
106 model: string;
107 scope: PlatformAgentScope;
108 enabled?: boolean;
109}
110
111export interface UpdatePlatformAgentInput {
112 name?: string;
113 provider?: string;
114 endpoint?: string | null;
115 /** When present, the stored key is re-encrypted with this new plaintext. */
116 apiKey?: string;
117 model?: string;
118 scope?: PlatformAgentScope;
119 enabled?: boolean;
120}
121
122/** All agents, masked, stable name order for the cockpit table. */
123export async function listPlatformAgents(): Promise<MaskedPlatformAgent[]> {
124 const db = getDb();
125 const rows = await db.select().from(platformAgents).orderBy(asc(platformAgents.name));
126 return rows.map(maskAgent);
127}
128
129async function getAgentRow(agentId: string): Promise<PlatformAgent> {
130 const db = getDb();
131 const [row] = await db
132 .select()
133 .from(platformAgents)
134 .where(eq(platformAgents.id, agentId))
135 .limit(1);
136 if (!row) throw new NotFoundError('agent', agentId);
137 return row;
138}
139
140/** 409 mapper for the unique name index — names are the registry identity. */
141function duplicateName(name: string): brivenError {
142 return new brivenError('duplicate', `an agent named "${name}" already exists`, {
143 status: 409,
144 });
145}
146
147/** True when the error is postgres unique_violation (code 23505). */
148function isUniqueViolation(err: unknown): boolean {
149 return (
150 typeof err === 'object' &&
151 err !== null &&
152 'code' in err &&
153 (err as { code: unknown }).code === '23505'
154 );
155}
156
157/**
158 * Register an agent. The api key (when given) is encrypted before the row is
159 * written; the returned shape is masked — the plaintext is NEVER echoed back,
160 * not even on this first response (the admin typed it; they already have it).
161 */
162export async function createPlatformAgent(
163 input: CreatePlatformAgentInput,
164 createdBy: string | null,
165): Promise<MaskedPlatformAgent> {
166 const db = getDb();
167 const agentId = newId('agt');
168 const apiKey = input.apiKey ?? null;
169 try {
170 const [row] = await db
171 .insert(platformAgents)
172 .values({
173 id: agentId,
174 name: input.name,
175 provider: input.provider,
176 endpoint: input.endpoint ?? null,
177 model: input.model,
178 scope: input.scope,
179 enabled: input.enabled ?? true,
180 encryptedApiKey: apiKey ? encryptAgentKey(agentId, apiKey) : null,
181 keyPrefix: apiKey ? maskAgentKey(apiKey).keyPrefix : null,
182 keySuffix: apiKey ? maskAgentKey(apiKey).keySuffix : null,
183 createdBy,
184 })
185 .returning();
186 if (!row) throw new Error('platform agent insert returned no row');
187 return maskAgent(row);
188 } catch (err) {
189 if (isUniqueViolation(err)) throw duplicateName(input.name);
190 throw err;
191 }
192}
193
194/**
195 * Patch an agent. Only the provided fields change; when `apiKey` is present
196 * the stored ciphertext is replaced with a fresh encryption of the new key
197 * (and the masked hint is refreshed alongside it).
198 */
199export async function updatePlatformAgent(
200 agentId: string,
201 patch: UpdatePlatformAgentInput,
202): Promise<MaskedPlatformAgent> {
203 await getAgentRow(agentId); // 404 before we attempt a write
204 const db = getDb();
205 const set: Partial<typeof platformAgents.$inferInsert> = { updatedAt: new Date() };
206 if (patch.name !== undefined) set.name = patch.name;
207 if (patch.provider !== undefined) set.provider = patch.provider;
208 if (patch.endpoint !== undefined) set.endpoint = patch.endpoint;
209 if (patch.model !== undefined) set.model = patch.model;
210 if (patch.scope !== undefined) set.scope = patch.scope;
211 if (patch.enabled !== undefined) set.enabled = patch.enabled;
212 if (patch.apiKey !== undefined) {
213 set.encryptedApiKey = encryptAgentKey(agentId, patch.apiKey);
214 const hint = maskAgentKey(patch.apiKey);
215 set.keyPrefix = hint.keyPrefix;
216 set.keySuffix = hint.keySuffix;
217 }
218 try {
219 const [row] = await db
220 .update(platformAgents)
221 .set(set)
222 .where(eq(platformAgents.id, agentId))
223 .returning();
224 if (!row) throw new NotFoundError('agent', agentId);
225 return maskAgent(row);
226 } catch (err) {
227 if (isUniqueViolation(err) && patch.name) throw duplicateName(patch.name);
228 throw err;
229 }
230}
231
232/** Hard-delete an agent row (ciphertext goes with it). 404 when unknown. */
233export async function deletePlatformAgent(agentId: string): Promise<void> {
234 await getAgentRow(agentId);
235 const db = getDb();
236 await db.delete(platformAgents).where(eq(platformAgents.id, agentId));
237}
238
239/* ─── connectivity test ──────────────────────────────────────────────────── */
240
241/**
242 * Default BASE urls per provider — mirrors the admin add-agent form's auto-fill
243 * (agents-client.tsx). Used when the agent has no explicit endpoint. These are
244 * BASE urls (most already end in /v1), NOT the models path — buildProviderTestUrl
245 * appends the right models path below.
246 */
247const PROVIDER_DEFAULT_BASES: Record<string, string> = {
248 anthropic: 'https://api.anthropic.com',
249 openai: 'https://api.openai.com/v1',
250 xai: 'https://api.x.ai/v1',
251 zai: 'https://api.z.ai/api/paas/v4',
252 deepseek: 'https://api.deepseek.com',
253 ollama: 'http://localhost:11434/v1',
254 flndrnai: 'https://ai.flndrn.com/v1',
255};
256
257/**
258 * Build the cheap "list models" URL used to probe reachability + key validity.
259 *
260 * The stored endpoint is a BASE url (the admin form auto-fills e.g.
261 * `https://api.anthropic.com` or `https://api.openai.com/v1`). Hitting the bare
262 * base returns 404 — THAT was the bug (the correct /v1/models url was only used
263 * as a no-endpoint fallback, which the auto-fill silently defeated). So:
264 * - anthropic isn't OpenAI-compatible: its models list is always
265 * `<origin>/v1/models` (probed with x-api-key + anthropic-version headers).
266 * - every other provider is OpenAI-compatible: the check is `<base>/models`
267 * (the base already carries any /v1 or /api/paas/v4 prefix). A 200 there
268 * means reachable AND the key is accepted; 401/403 means the key was rejected.
269 * Verified against live endpoints 2026-07-04: bare origin -> 404, /v1/models -> 401.
270 * Falls back to the provider default base when no endpoint is set; returns null
271 * only for an unknown provider with no endpoint.
272 */
273function buildProviderTestUrl(provider: string, endpoint: string | null): string | null {
274 if (provider === 'anthropic') {
275 const base = endpoint ?? PROVIDER_DEFAULT_BASES.anthropic;
276 if (!base) return null;
277 try {
278 const u = new URL(base);
279 return `${u.protocol}//${u.host}/v1/models`;
280 } catch {
281 return null;
282 }
283 }
284 const base = endpoint ?? PROVIDER_DEFAULT_BASES[provider] ?? null;
285 if (!base) return null;
286 const trimmed = base.replace(/\/+$/, '');
287 // Already a models url? use as-is. Otherwise append the OpenAI-compat path.
288 return /\/models$/.test(trimmed) ? trimmed : `${trimmed}/models`;
289}
290
291const TEST_TIMEOUT_MS = 5_000;
292
293export interface AgentTestResult {
294 ok: boolean;
295 /** HTTP status from the provider, when the request got that far. */
296 status: number | null;
297 message: string;
298}
299
300/**
301 * Server-side connectivity ping. Decrypts the key IN MEMORY ONLY, sends one
302 * GET to the agent's endpoint (or the provider's well-known models URL) with
303 * the provider-appropriate auth header, and reports reachable / rejected /
304 * unreachable. The key and the raw provider error are never logged and never
305 * leave this function — only { ok, status, message } comes back.
306 */
307export async function testPlatformAgent(agentId: string): Promise<AgentTestResult> {
308 const agent = await getAgentRow(agentId);
309
310 const url = buildProviderTestUrl(agent.provider, agent.endpoint);
311 if (!url) {
312 return {
313 ok: false,
314 status: null,
315 message: `no endpoint configured and no default url is known for provider "${agent.provider}"`,
316 };
317 }
318 let parsed: URL;
319 try {
320 parsed = new URL(url);
321 } catch {
322 return { ok: false, status: null, message: 'endpoint is not a valid url' };
323 }
324 if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
325 return { ok: false, status: null, message: 'endpoint must be an http(s) url' };
326 }
327
328 const headers: Record<string, string> = {};
329 if (agent.encryptedApiKey) {
330 let plaintext: string;
331 try {
332 plaintext = decryptAgentKey(agentId, agent.encryptedApiKey);
333 } catch (err) {
334 // Master key missing / rotated without a re-encrypt migration. Surface
335 // a plain-words failure; never the crypto internals.
336 if (err instanceof ValidationError) {
337 return { ok: false, status: null, message: err.message };
338 }
339 return { ok: false, status: null, message: 'stored key could not be decrypted' };
340 }
341 if (agent.provider === 'anthropic') {
342 headers['x-api-key'] = plaintext;
343 headers['anthropic-version'] = '2023-06-01';
344 } else {
345 headers['Authorization'] = `Bearer ${plaintext}`;
346 }
347 }
348
349 try {
350 const res = await fetch(parsed, {
351 method: 'GET',
352 headers,
353 signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
354 redirect: 'manual',
355 });
356 if (res.ok) {
357 return { ok: true, status: res.status, message: 'endpoint reachable' };
358 }
359 if (res.status === 401 || res.status === 403) {
360 return {
361 ok: false,
362 status: res.status,
363 message: 'endpoint reachable but the key was rejected',
364 };
365 }
366 return {
367 ok: false,
368 status: res.status,
369 message: `endpoint reachable but returned status ${res.status}`,
370 };
371 } catch {
372 // Timeout, DNS failure, refused connection — the raw error can embed the
373 // request (and thus headers), so it is deliberately not propagated.
374 return { ok: false, status: null, message: 'endpoint unreachable (timeout or network error)' };
375 }
376}