platform-agents.ts376 lines · main
| 1 | import { brivenError, newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 2 | |
| 3 | import { asc, eq } from 'drizzle-orm'; |
| 4 | |
| 5 | import { getDb } from '../db/client.js'; |
| 6 | import { |
| 7 | platformAgents, |
| 8 | type PlatformAgent, |
| 9 | type PlatformAgentScope, |
| 10 | } from '../db/schema.js'; |
| 11 | import { 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. */ |
| 37 | const KEY_PREFIX_CHARS = 4; |
| 38 | const 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 | */ |
| 45 | export 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 | |
| 52 | export 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. */ |
| 69 | export 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 | |
| 88 | function 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 | |
| 94 | function decryptAgentKey(agentId: string, ciphertext: string): string { |
| 95 | return decryptTenantSecret({ service: 'auth', projectId: agentId, ciphertext }); |
| 96 | } |
| 97 | |
| 98 | /* ─── CRUD ───────────────────────────────────────────────────────────────── */ |
| 99 | |
| 100 | export 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 | |
| 111 | export 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. */ |
| 123 | export 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 | |
| 129 | async 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. */ |
| 141 | function 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). */ |
| 148 | function 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 | */ |
| 162 | export 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 | */ |
| 199 | export 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. */ |
| 233 | export 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 | */ |
| 247 | const 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 | */ |
| 273 | function 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 | |
| 291 | const TEST_TIMEOUT_MS = 5_000; |
| 292 | |
| 293 | export 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 | */ |
| 307 | export 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 | } |