diff --git a/src/main/apiServer/routes/agents/middleware/common.ts b/src/main/apiServer/routes/agents/middleware/common.ts index b68c5f3bab..582384056b 100644 --- a/src/main/apiServer/routes/agents/middleware/common.ts +++ b/src/main/apiServer/routes/agents/middleware/common.ts @@ -1,24 +1,12 @@ import { Request, Response } from 'express' -import { validationResult } from 'express-validator' import { agentService } from '../../../../services/agents' import { loggerService } from '../../../../services/LoggerService' const logger = loggerService.withContext('ApiServerMiddleware') -// Error handler for validation -export const handleValidationErrors = (req: Request, res: Response, next: any): void => { - const errors = validationResult(req) - if (!errors.isEmpty()) { - res.status(400).json({ - error: { - message: 'Validation failed', - type: 'validation_error', - details: errors.array() - } - }) - return - } +// Since Zod validators handle their own errors, this is now a pass-through +export const handleValidationErrors = (_req: Request, _res: Response, next: any): void => { next() } diff --git a/src/main/apiServer/routes/agents/validators/agents.ts b/src/main/apiServer/routes/agents/validators/agents.ts index 9cdbabfcc3..d9c23b13f1 100644 --- a/src/main/apiServer/routes/agents/validators/agents.ts +++ b/src/main/apiServer/routes/agents/validators/agents.ts @@ -1,37 +1,15 @@ -import { body, param } from 'express-validator' +import { AgentIdParamSchema, CreateAgentRequestSchema, UpdateAgentRequestSchema } from '@types' -export const validateAgent = [ - body('name').notEmpty().withMessage('Name is required'), - body('model').notEmpty().withMessage('Model is required'), - body('description').optional().isString(), - body('avatar').optional().isString(), - body('instructions').optional().isString(), - body('plan_model').optional().isString(), - body('small_model').optional().isString(), - body('built_in_tools').optional().isArray(), - body('mcps').optional().isArray(), - body('knowledges').optional().isArray(), - body('configuration').optional().isObject(), - body('accessible_paths').optional().isArray(), - body('permission_mode').optional().isIn(['readOnly', 'acceptEdits', 'bypassPermissions']), - body('max_steps').optional().isInt({ min: 1 }) -] +import { createZodValidator } from './zodValidator' -export const validateAgentUpdate = [ - body('name').optional().notEmpty().withMessage('Name cannot be empty'), - body('model').optional().notEmpty().withMessage('Model cannot be empty'), - body('description').optional().isString(), - body('avatar').optional().isString(), - body('instructions').optional().isString(), - body('plan_model').optional().isString(), - body('small_model').optional().isString(), - body('built_in_tools').optional().isArray(), - body('mcps').optional().isArray(), - body('knowledges').optional().isArray(), - body('configuration').optional().isObject(), - body('accessible_paths').optional().isArray(), - body('permission_mode').optional().isIn(['readOnly', 'acceptEdits', 'bypassPermissions']), - body('max_steps').optional().isInt({ min: 1 }) -] +export const validateAgent = createZodValidator({ + body: CreateAgentRequestSchema +}) -export const validateAgentId = [param('agentId').notEmpty().withMessage('Agent ID is required')] +export const validateAgentUpdate = createZodValidator({ + body: UpdateAgentRequestSchema +}) + +export const validateAgentId = createZodValidator({ + params: AgentIdParamSchema +}) diff --git a/src/main/apiServer/routes/agents/validators/common.ts b/src/main/apiServer/routes/agents/validators/common.ts index 1d9a811466..4e9a8ceaa2 100644 --- a/src/main/apiServer/routes/agents/validators/common.ts +++ b/src/main/apiServer/routes/agents/validators/common.ts @@ -1,10 +1,7 @@ -import { query } from 'express-validator' +import { PaginationQuerySchema } from '@types' -export const validatePagination = [ - query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100'), - query('offset').optional().isInt({ min: 0 }).withMessage('Offset must be non-negative'), - query('status') - .optional() - .isIn(['idle', 'running', 'completed', 'failed', 'stopped']) - .withMessage('Invalid status filter') -] +import { createZodValidator } from './zodValidator' + +export const validatePagination = createZodValidator({ + query: PaginationQuerySchema +}) diff --git a/src/main/apiServer/routes/agents/validators/messages.ts b/src/main/apiServer/routes/agents/validators/messages.ts index 84de744620..9a4f7dbbf0 100644 --- a/src/main/apiServer/routes/agents/validators/messages.ts +++ b/src/main/apiServer/routes/agents/validators/messages.ts @@ -1,5 +1,7 @@ -import { body } from 'express-validator' +import { CreateSessionMessageRequestSchema } from '@types' -export const validateSessionMessage = [ - body('content').notEmpty().isString().withMessage('Content must be a valid string') -] +import { createZodValidator } from './zodValidator' + +export const validateSessionMessage = createZodValidator({ + body: CreateSessionMessageRequestSchema +}) diff --git a/src/main/apiServer/routes/agents/validators/sessions.ts b/src/main/apiServer/routes/agents/validators/sessions.ts index 6accf2f401..3c87349230 100644 --- a/src/main/apiServer/routes/agents/validators/sessions.ts +++ b/src/main/apiServer/routes/agents/validators/sessions.ts @@ -1,47 +1,15 @@ -import { body, param } from 'express-validator' +import { CreateSessionRequestSchema, SessionIdParamSchema, UpdateSessionRequestSchema } from '@types' -export const validateSession = [ - body('name').optional().isString(), - body('sub_agent_ids').optional().isArray(), - body('user_goal').optional().isString(), - body('status').optional().isIn(['idle', 'running', 'completed', 'failed', 'stopped']), - body('external_session_id').optional().isString(), - body('model').optional().isString(), - body('plan_model').optional().isString(), - body('small_model').optional().isString(), - body('built_in_tools').optional().isArray(), - body('mcps').optional().isArray(), - body('knowledges').optional().isArray(), - body('configuration').optional().isObject(), - body('accessible_paths').optional().isArray(), - body('permission_mode').optional().isIn(['readOnly', 'acceptEdits', 'bypassPermissions']), - body('max_steps').optional().isInt({ min: 1 }) -] +import { createZodValidator } from './zodValidator' -export const validateSessionUpdate = [ - body('name').optional().isString(), - body('main_agent_id').optional().notEmpty().withMessage('Main agent ID cannot be empty'), - body('sub_agent_ids').optional().isArray(), - body('user_goal').optional().isString(), - body('status').optional().isIn(['idle', 'running', 'completed', 'failed', 'stopped']), - body('external_session_id').optional().isString(), - body('model').optional().isString(), - body('plan_model').optional().isString(), - body('small_model').optional().isString(), - body('built_in_tools').optional().isArray(), - body('mcps').optional().isArray(), - body('knowledges').optional().isArray(), - body('configuration').optional().isObject(), - body('accessible_paths').optional().isArray(), - body('permission_mode').optional().isIn(['readOnly', 'acceptEdits', 'bypassPermissions']), - body('max_steps').optional().isInt({ min: 1 }) -] +export const validateSession = createZodValidator({ + body: CreateSessionRequestSchema +}) -export const validateStatusUpdate = [ - body('status') - .notEmpty() - .isIn(['idle', 'running', 'completed', 'failed', 'stopped']) - .withMessage('Valid status is required') -] +export const validateSessionUpdate = createZodValidator({ + body: UpdateSessionRequestSchema +}) -export const validateSessionId = [param('sessionId').notEmpty().withMessage('Session ID is required')] +export const validateSessionId = createZodValidator({ + params: SessionIdParamSchema +}) diff --git a/src/main/apiServer/routes/agents/validators/zodValidator.ts b/src/main/apiServer/routes/agents/validators/zodValidator.ts new file mode 100644 index 0000000000..ce8a1cb68f --- /dev/null +++ b/src/main/apiServer/routes/agents/validators/zodValidator.ts @@ -0,0 +1,68 @@ +import { NextFunction,Request, Response } from 'express' +import { ZodError, ZodType } from 'zod' + +export interface ValidationRequest extends Request { + validatedBody?: any + validatedParams?: any + validatedQuery?: any +} + +export interface ZodValidationConfig { + body?: ZodType + params?: ZodType + query?: ZodType +} + +export const createZodValidator = (config: ZodValidationConfig) => { + return (req: ValidationRequest, res: Response, next: NextFunction): void => { + try { + if (config.body && req.body) { + req.validatedBody = config.body.parse(req.body) + } + + if (config.params && req.params) { + req.validatedParams = config.params.parse(req.params) + } + + if (config.query && req.query) { + req.validatedQuery = config.query.parse(req.query) + } + + next() + } catch (error) { + if (error instanceof ZodError) { + const validationErrors = error.issues.map((err) => ({ + type: 'field', + value: err.input, + msg: err.message, + path: err.path.map(p => String(p)).join('.'), + location: getLocationFromPath(err.path, config) + })) + + res.status(400).json({ + error: { + message: 'Validation failed', + type: 'validation_error', + details: validationErrors + } + }) + return + } + + res.status(500).json({ + error: { + message: 'Internal validation error', + type: 'internal_error', + code: 'validation_processing_failed' + } + }) + } + } +} + +function getLocationFromPath(path: (string | number | symbol)[], config: ZodValidationConfig): string { + if (config.body && path.length > 0) return 'body' + if (config.params && path.length > 0) return 'params' + if (config.query && path.length > 0) return 'query' + return 'unknown' +} \ No newline at end of file diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index c8e9a3112b..cad935d0e8 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -5,11 +5,21 @@ import { ModelMessage, modelMessageSchema, TextStreamPart, UIMessageChunk } from 'ai' import { z } from 'zod' +// ------------------ Core enums and helper types ------------------ export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']) export type PermissionMode = z.infer export type SessionMessageRole = ModelMessage['role'] +const sessionMessageRoles = ['assistant', 'user', 'system', 'tool'] as const satisfies readonly [ + SessionMessageRole, + ...SessionMessageRole[] +] + +export const SessionMessageRoleSchema = z.enum(sessionMessageRoles) + +export type SessionMessageType = TextStreamPart>['type'] + export const AgentTypeSchema = z.enum(['claude-code']) export type AgentType = z.infer @@ -17,8 +27,7 @@ export const isAgentType = (type: unknown): type is AgentType => { return AgentTypeSchema.safeParse(type).success } -export type SessionMessageType = TextStreamPart>['type'] - +// ------------------ Tool metadata ------------------ export const ToolSchema = z.object({ id: z.string(), name: z.string(), @@ -28,6 +37,7 @@ export const ToolSchema = z.object({ export type Tool = z.infer +// ------------------ Agent configuration & base schema ------------------ export const AgentConfigurationSchema = z .object({ permission_mode: PermissionModeSchema.default('default'), // Permission mode, default to 'default' @@ -62,6 +72,8 @@ export const AgentBaseSchema = z.object({ export type AgentBase = z.infer +// ------------------ Persistence entities ------------------ + // Agent entity representing an autonomous agent configuration export const AgentEntitySchema = AgentBaseSchema.extend({ id: z.string(), @@ -95,7 +107,7 @@ export const AgentSessionMessageEntitySchema = z.object({ id: z.number(), // Auto-increment primary key session_id: z.string(), // Reference to session // manual defined. may not synced with ai sdk definition - role: z.enum(['assistant', 'user', 'system', 'tool']), // 'assistant' | 'user' | 'system' | 'tool' + role: SessionMessageRoleSchema, // Enforce roles supported by modelMessageSchema content: modelMessageSchema, agent_session_id: z.string(), // agent session id, use to resume agent session metadata: z.record(z.string(), z.any()).optional(), // Additional metadata (optional) @@ -105,6 +117,8 @@ export const AgentSessionMessageEntitySchema = z.object({ export type AgentSessionMessageEntity = z.infer +// ------------------ Session message payload ------------------ + // Structured content for session messages that preserves both AI SDK and raw data export interface SessionMessageContent { chunk: UIMessageChunk[] // UI-friendly AI SDK chunks for rendering @@ -119,10 +133,11 @@ export interface SessionMessageContent { // - mcps: Optional array of MCP (Model Control Protocol) tool IDs // - allowed_tools: Optional array of permitted tool IDs // - configuration: Optional agent settings (temperature, top_p, etc.) -export interface BaseAgentForm { +// ------------------ Form models ------------------ +export type BaseAgentForm = { id?: string type: AgentType - // These fileds should be editable by user. + // These fields should be editable by user. name: string description?: string instructions?: string @@ -130,30 +145,22 @@ export interface BaseAgentForm { accessible_paths: string[] } -export interface AddAgentForm extends BaseAgentForm { - id?: never -} +export type AddAgentForm = Omit & { id?: never } -export interface UpdateAgentForm extends Partial { +export type UpdateAgentForm = Partial> & { id: string type?: never } export type AgentForm = AddAgentForm | UpdateAgentForm -export interface BaseSessionForm extends AgentBase {} +export type BaseSessionForm = AgentBase -export interface CreateSessionForm extends BaseSessionForm { - id?: never -} +export type CreateSessionForm = BaseSessionForm & { id?: never } -export interface UpdateSessionForm extends Partial { - id: string -} +export type UpdateSessionForm = Partial & { id: string } -// ------------------------ -// API Data Transfer Object -// ------------------------ +// ------------------ API data transfer objects ------------------ export interface CreateAgentRequest extends AgentBase { type: AgentType } @@ -220,3 +227,45 @@ export const AgentServerErrorSchema = z.object({ }) export type AgentServerError = z.infer + +// ------------------ API validation schemas ------------------ + +// Parameter validation schemas +export const AgentIdParamSchema = z.object({ + agentId: z.string().min(1, 'Agent ID is required') +}) + +export const SessionIdParamSchema = z.object({ + sessionId: z.string().min(1, 'Session ID is required') +}) + +// Query validation schemas +export const PaginationQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), + status: z.enum(['idle', 'running', 'completed', 'failed', 'stopped']).optional() +}) + +// Request body validation schemas derived from shared bases +const agentCreatableSchema = AgentBaseSchema.extend({ + name: z.string().min(1, 'Name is required'), + model: z.string().min(1, 'Model is required') +}) + +export const CreateAgentRequestSchema = agentCreatableSchema.extend({ + type: AgentTypeSchema +}) + +export const UpdateAgentRequestSchema = AgentBaseSchema.partial() + +const sessionCreatableSchema = AgentBaseSchema.extend({ + model: z.string().min(1, 'Model is required') +}) + +export const CreateSessionRequestSchema = sessionCreatableSchema + +export const UpdateSessionRequestSchema = sessionCreatableSchema.partial() + +export const CreateSessionMessageRequestSchema = z.object({ + content: z.string().min(1, 'Content must be a valid string') +})