admin-agents.ts181 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { requireAdmin } from '../middleware/admin.js';
5import { requireAuth } from '../middleware/session.js';
6import { requireRecentMfa } from '../middleware/step-up.js';
7import type { AppEnv } from '../types/app-env.js';
8import { platformAgentScope } from '../db/schema.js';
9import { audit, hashIp } from '../services/audit.js';
10import {
11 createPlatformAgent,
12 deletePlatformAgent,
13 listPlatformAgents,
14 testPlatformAgent,
15 updatePlatformAgent,
16} from '../services/platform-agents.js';
17
18/**
19 * Admin AI-agent manager — /v1/admin/agents. The write surface behind the
20 * cockpit's "AI Agents" page: register named agents (provider + endpoint +
21 * model + scope), rotate their encrypted provider keys, flip them on/off,
22 * and ping their endpoints. Split out of routes/admin.ts the same way
23 * admin-manifest.ts is, to keep that file from growing without bound.
24 *
25 * Guard chain mirrors admin.ts EXACTLY: session → admin bit → step-up
26 * freshness on every mutation (reads pass so the page renders without
27 * re-prompting). The api key is accepted as input, encrypted at rest via
28 * services/platform-agents.ts, and NEVER returned or logged — audit rows
29 * record only that a key was set/rotated, never its value.
30 */
31
32function ipHash(c: Context<AppEnv>): string | null {
33 const fwd = c.req.raw.headers.get('x-forwarded-for');
34 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
35 return hashIp(ip);
36}
37
38export const adminAgentsRouter = new Hono<AppEnv>();
39
40for (const path of ['/v1/admin/agents', '/v1/admin/agents/*'] as const) {
41 adminAgentsRouter.use(path, requireAuth());
42 adminAgentsRouter.use(path, requireAdmin());
43}
44// Every agent MUTATION requires fresh step-up auth per CLAUDE.md §5.4 —
45// same method-gated pattern as routes/admin.ts.
46const mfa = requireRecentMfa(10);
47for (const path of ['/v1/admin/agents', '/v1/admin/agents/*'] as const) {
48 adminAgentsRouter.use(path, async (c, next) => {
49 const method = c.req.method;
50 if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
51 return next();
52 }
53 return mfa(c, next);
54 });
55}
56
57/* ─── validation ─────────────────────────────────────────────────────── */
58
59const scopeSchema = z.enum(platformAgentScope);
60// Min 12 keeps the masked prefix(4)+suffix(4) hint from ever revealing a
61// whole key; max 4096 bounds the ciphertext column.
62const apiKeySchema = z.string().min(12).max(4096);
63const endpointSchema = z.string().url().max(2000);
64
65const createAgentSchema = z.object({
66 name: z.string().min(1).max(120),
67 provider: z.string().min(1).max(40),
68 endpoint: endpointSchema.optional(),
69 apiKey: apiKeySchema.optional(),
70 model: z.string().min(1).max(120),
71 scope: scopeSchema,
72 enabled: z.boolean().optional(),
73});
74
75const updateAgentSchema = z
76 .object({
77 name: z.string().min(1).max(120).optional(),
78 provider: z.string().min(1).max(40).optional(),
79 // Nullable so an admin can clear a stale endpoint back to the provider
80 // default without deleting the agent.
81 endpoint: endpointSchema.nullable().optional(),
82 apiKey: apiKeySchema.optional(),
83 model: z.string().min(1).max(120).optional(),
84 scope: scopeSchema.optional(),
85 enabled: z.boolean().optional(),
86 })
87 .refine((v) => Object.keys(v).length > 0, { message: 'at least one field is required' });
88
89/* ─── routes ─────────────────────────────────────────────────────────── */
90
91adminAgentsRouter.get('/v1/admin/agents', async (c) => {
92 const agents = await listPlatformAgents();
93 return c.json({ agents });
94});
95
96adminAgentsRouter.post('/v1/admin/agents', async (c) => {
97 const actor = c.get('user')!;
98 const body = await c.req.json().catch(() => null);
99 const parsed = createAgentSchema.safeParse(body);
100 if (!parsed.success) {
101 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
102 }
103 const agent = await createPlatformAgent(parsed.data, actor.id);
104 await audit({
105 actorId: actor.id,
106 projectId: null,
107 action: 'admin.agent.create',
108 ipHash: ipHash(c),
109 userAgent: c.req.header('user-agent') ?? null,
110 // Never the key — only whether one was supplied.
111 metadata: {
112 agentId: agent.id,
113 name: agent.name,
114 provider: agent.provider,
115 scope: agent.scope,
116 keySet: agent.hasKey,
117 },
118 });
119 return c.json({ agent }, 201);
120});
121
122adminAgentsRouter.patch('/v1/admin/agents/:id', async (c) => {
123 const actor = c.get('user')!;
124 const agentId = c.req.param('id');
125 const body = await c.req.json().catch(() => null);
126 const parsed = updateAgentSchema.safeParse(body);
127 if (!parsed.success) {
128 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
129 }
130 const { apiKey, ...rest } = parsed.data;
131 const agent = await updatePlatformAgent(agentId, parsed.data);
132 await audit({
133 actorId: actor.id,
134 projectId: null,
135 action: 'admin.agent.update',
136 ipHash: ipHash(c),
137 userAgent: c.req.header('user-agent') ?? null,
138 metadata: {
139 agentId,
140 fields: Object.keys(rest),
141 keyRotated: apiKey !== undefined,
142 },
143 });
144 return c.json({ agent });
145});
146
147/**
148 * Server-side connectivity ping — decrypts the key in memory, GETs the
149 * agent's endpoint (or the provider's well-known models url), and reports
150 * ok / rejected / unreachable. POST (not GET) because it spends real
151 * network I/O against a third party, so it belongs behind the step-up gate.
152 */
153adminAgentsRouter.post('/v1/admin/agents/:id/test', async (c) => {
154 const actor = c.get('user')!;
155 const agentId = c.req.param('id');
156 const result = await testPlatformAgent(agentId);
157 await audit({
158 actorId: actor.id,
159 projectId: null,
160 action: 'admin.agent.test',
161 ipHash: ipHash(c),
162 userAgent: c.req.header('user-agent') ?? null,
163 metadata: { agentId, ok: result.ok, status: result.status },
164 });
165 return c.json({ agentId, ...result });
166});
167
168adminAgentsRouter.delete('/v1/admin/agents/:id', async (c) => {
169 const actor = c.get('user')!;
170 const agentId = c.req.param('id');
171 await deletePlatformAgent(agentId);
172 await audit({
173 actorId: actor.id,
174 projectId: null,
175 action: 'admin.agent.delete',
176 ipHash: ipHash(c),
177 userAgent: c.req.header('user-agent') ?? null,
178 metadata: { agentId },
179 });
180 return c.json({ ok: true });
181});