Skip to content

Commit 26066c2

Browse files
Add agent validation system with remote API endpoint
Implements agent name validation to prevent invalid agent references: - New GET /api/agents/validate-name endpoint with 5-min cache - Client-side validation with graceful network error handling - Validates against built-in and published agents - Exits early on unknown agents with clear error message 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 82c41df commit 26066c2

File tree

5 files changed

+387
-11
lines changed

5 files changed

+387
-11
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { AGENT_PERSONAS } from '@codebuff/common/constants/agents'
2+
import {
3+
describe,
4+
it,
5+
expect,
6+
beforeEach,
7+
afterEach,
8+
spyOn,
9+
mock,
10+
} from 'bun:test'
11+
12+
import * as agentRegistry from '../../templates/agent-registry'
13+
import { validateAgentNameHandler } from '../agents'
14+
15+
import type {
16+
Request as ExpressRequest,
17+
Response as ExpressResponse,
18+
NextFunction,
19+
} from 'express'
20+
21+
function createMockReq(query: Record<string, any>): Partial<ExpressRequest> {
22+
return { query } as any
23+
}
24+
25+
function createMockRes() {
26+
const res: Partial<ExpressResponse> & {
27+
statusCode?: number
28+
jsonPayload?: any
29+
} = {}
30+
res.status = mock((code: number) => {
31+
res.statusCode = code
32+
return res as ExpressResponse
33+
}) as any
34+
res.json = mock((payload: any) => {
35+
res.jsonPayload = payload
36+
return res as ExpressResponse
37+
}) as any
38+
return res as ExpressResponse & { statusCode?: number; jsonPayload?: any }
39+
}
40+
41+
const noopNext: NextFunction = () => {}
42+
43+
describe('validateAgentNameHandler', () => {
44+
const builtinAgentId = Object.keys(AGENT_PERSONAS)[0] || 'file-picker'
45+
46+
beforeEach(() => {
47+
mock.restore()
48+
})
49+
50+
afterEach(() => {
51+
mock.restore()
52+
})
53+
54+
it('returns valid=true for builtin agent ids', async () => {
55+
const req = createMockReq({ agentId: builtinAgentId })
56+
const res = createMockRes()
57+
58+
await validateAgentNameHandler(req as any, res as any, noopNext)
59+
60+
expect(res.status).toHaveBeenCalledWith(200)
61+
expect(res.json).toHaveBeenCalled()
62+
expect(res.jsonPayload.valid).toBe(true)
63+
expect(res.jsonPayload.source).toBe('builtin')
64+
expect(res.jsonPayload.normalizedId).toBe(builtinAgentId)
65+
})
66+
67+
it('returns valid=true for published agent ids (publisher/name)', async () => {
68+
const agentId = 'codebuff/file-explorer'
69+
70+
const spy = spyOn(agentRegistry, 'getAgentTemplate')
71+
spy.mockResolvedValueOnce({ id: 'codebuff/file-explorer@0.0.1' } as any)
72+
73+
const req = createMockReq({ agentId })
74+
const res = createMockRes()
75+
76+
await validateAgentNameHandler(req as any, res as any, noopNext)
77+
78+
expect(spy).toHaveBeenCalledWith(agentId, {})
79+
expect(res.status).toHaveBeenCalledWith(200)
80+
expect(res.jsonPayload.valid).toBe(true)
81+
expect(res.jsonPayload.source).toBe('published')
82+
expect(res.jsonPayload.normalizedId).toBe('codebuff/file-explorer@0.0.1')
83+
})
84+
85+
it('returns valid=true for versioned published agent ids (publisher/name@version)', async () => {
86+
const agentId = 'codebuff/file-explorer@0.0.1'
87+
88+
const spy = spyOn(agentRegistry, 'getAgentTemplate')
89+
spy.mockResolvedValueOnce({ id: agentId } as any)
90+
91+
const req = createMockReq({ agentId })
92+
const res = createMockRes()
93+
94+
await validateAgentNameHandler(req as any, res as any, noopNext)
95+
96+
expect(spy).toHaveBeenCalledWith(agentId, {})
97+
expect(res.status).toHaveBeenCalledWith(200)
98+
expect(res.jsonPayload.valid).toBe(true)
99+
expect(res.jsonPayload.source).toBe('published')
100+
expect(res.jsonPayload.normalizedId).toBe(agentId)
101+
})
102+
103+
it('returns valid=false for unknown agents', async () => {
104+
const agentId = 'someorg/not-a-real-agent'
105+
106+
const spy = spyOn(agentRegistry, 'getAgentTemplate')
107+
spy.mockResolvedValueOnce(null)
108+
109+
const req = createMockReq({ agentId })
110+
const res = createMockRes()
111+
112+
await validateAgentNameHandler(req as any, res as any, noopNext)
113+
114+
expect(spy).toHaveBeenCalledWith(agentId, {})
115+
expect(res.status).toHaveBeenCalledWith(200)
116+
expect(res.jsonPayload.valid).toBe(false)
117+
})
118+
119+
it('returns 400 for invalid requests (missing agentId)', async () => {
120+
const req = createMockReq({})
121+
const res = createMockRes()
122+
123+
await validateAgentNameHandler(req as any, res as any, noopNext)
124+
125+
// Handler normalizes zod errors to 400
126+
expect(res.status).toHaveBeenCalledWith(400)
127+
expect(res.jsonPayload.valid).toBe(false)
128+
expect(res.jsonPayload.message).toBe('Invalid request')
129+
})
130+
})

backend/src/api/agents.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { z } from 'zod/v4'
2+
import type {
3+
Request as ExpressRequest,
4+
Response as ExpressResponse,
5+
NextFunction,
6+
} from 'express'
7+
import { logger } from '../util/logger'
8+
import { AGENT_PERSONAS } from '@codebuff/common/constants/agents'
9+
import { getAgentTemplate } from '../templates/agent-registry'
10+
11+
// Add short-lived cache for positive validations
12+
const AGENT_VALIDATION_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
13+
14+
type CacheEntry = {
15+
result: { valid: true; source?: string; normalizedId?: string }
16+
expiresAt: number
17+
}
18+
19+
const agentValidationCache = new Map<string, CacheEntry>()
20+
21+
// Simple request schema
22+
const validateAgentRequestSchema = z.object({
23+
agentId: z.string().min(1),
24+
})
25+
26+
// GET /api/agents/validate-name
27+
export async function validateAgentNameHandler(
28+
req: ExpressRequest,
29+
res: ExpressResponse,
30+
next: NextFunction,
31+
): Promise<void | ExpressResponse> {
32+
try {
33+
// Log authentication headers if present (for debugging)
34+
const hasAuthHeader = !!req.headers.authorization
35+
const hasApiKey = !!req.headers['x-api-key']
36+
37+
if (hasAuthHeader || hasApiKey) {
38+
logger.info(
39+
{
40+
hasAuthHeader,
41+
hasApiKey,
42+
agentId: req.query.agentId,
43+
},
44+
'Agent validation request with authentication',
45+
)
46+
}
47+
48+
// Parse from query instead (GET)
49+
const { agentId } = validateAgentRequestSchema.parse({
50+
agentId: String((req.query as any)?.agentId ?? ''),
51+
})
52+
53+
// Check cache (positive results only)
54+
const cached = agentValidationCache.get(agentId)
55+
if (cached && cached.expiresAt > Date.now()) {
56+
return res.status(200).json({ ...cached.result, cached: true })
57+
} else if (cached) {
58+
agentValidationCache.delete(agentId)
59+
}
60+
61+
// Check built-in agents first
62+
if (AGENT_PERSONAS[agentId as keyof typeof AGENT_PERSONAS]) {
63+
const result = { valid: true as const, source: 'builtin', normalizedId: agentId }
64+
agentValidationCache.set(agentId, {
65+
result,
66+
expiresAt: Date.now() + AGENT_VALIDATION_CACHE_TTL_MS,
67+
})
68+
return res.status(200).json(result)
69+
}
70+
71+
// Check published agents (database)
72+
const found = await getAgentTemplate(agentId, {})
73+
if (found) {
74+
const result = {
75+
valid: true as const,
76+
source: 'published',
77+
normalizedId: found.id,
78+
}
79+
agentValidationCache.set(agentId, {
80+
result,
81+
expiresAt: Date.now() + AGENT_VALIDATION_CACHE_TTL_MS,
82+
})
83+
return res.status(200).json(result)
84+
}
85+
86+
return res.status(200).json({ valid: false })
87+
} catch (error) {
88+
logger.error(
89+
{ error: error instanceof Error ? error.message : String(error) },
90+
'Error validating agent name',
91+
)
92+
if (error instanceof z.ZodError) {
93+
return res.status(400).json({ valid: false, message: 'Invalid request', issues: error.issues })
94+
}
95+
next(error)
96+
return
97+
}
98+
}

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getTracesForUserHandler,
1111
relabelForUserHandler,
1212
} from './admin/relabelRuns'
13+
import { validateAgentNameHandler } from './api/agents'
1314
import { isRepoCoveredHandler } from './api/org'
1415
import usageHandler from './api/usage'
1516
import { checkAdmin } from './util/check-auth'
@@ -35,6 +36,7 @@ app.get('/healthz', (req, res) => {
3536

3637
app.post('/api/usage', usageHandler)
3738
app.post('/api/orgs/is-repo-covered', isRepoCoveredHandler)
39+
app.get('/api/agents/validate-name', validateAgentNameHandler)
3840

3941
// Enable CORS for preflight requests to the admin relabel endpoint
4042
app.options('/api/admin/relabel-for-user', cors())
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
beforeEach,
6+
afterEach,
7+
spyOn,
8+
mock,
9+
} from 'bun:test'
10+
11+
import { validateAgent } from '../index'
12+
import * as SpinnerMod from '../utils/spinner'
13+
14+
describe('validateAgent agent pass-through', () => {
15+
let fetchSpy: ReturnType<typeof spyOn>
16+
let spinnerSpy: ReturnType<typeof spyOn>
17+
18+
beforeEach(() => {
19+
fetchSpy = spyOn(globalThis as any, 'fetch').mockResolvedValue({
20+
ok: true,
21+
json: async () => ({ valid: true }),
22+
} as any)
23+
24+
spinnerSpy = spyOn(SpinnerMod.Spinner, 'get').mockReturnValue({
25+
start: () => {},
26+
stop: () => {},
27+
} as any)
28+
})
29+
30+
afterEach(() => {
31+
mock.restore()
32+
})
33+
34+
it('passes published agent id unchanged to backend (publisher/name@version)', async () => {
35+
const agent = 'codebuff/file-explorer@0.0.1'
36+
await validateAgent(agent, {})
37+
38+
expect(fetchSpy).toHaveBeenCalled()
39+
const url = (fetchSpy.mock.calls[0] as any[])[0] as string
40+
const u = new URL(url)
41+
expect(u.searchParams.get('agentId')).toBe(agent)
42+
})
43+
44+
it('short-circuits when agent is found locally (by id)', async () => {
45+
const agent = 'codebuff/file-explorer@0.0.1'
46+
fetchSpy.mockClear()
47+
48+
await validateAgent(agent, {
49+
[agent]: { displayName: 'File Explorer' },
50+
})
51+
52+
expect(fetchSpy).not.toHaveBeenCalled()
53+
})
54+
})

0 commit comments

Comments
 (0)