Spaces:
Sleeping
Sleeping
nyk
fix: migrate clawdbot CLI calls to gateway RPC and improve session labels (#407)
608b8db unverified | import { NextRequest, NextResponse } from 'next/server' | |
| import { requireRole } from '@/lib/auth' | |
| import { callOpenClawGateway } from '@/lib/openclaw-gateway' | |
| import { config } from '@/lib/config' | |
| import { readdir, readFile, stat } from 'fs/promises' | |
| import { join } from 'path' | |
| import { heavyLimiter } from '@/lib/rate-limit' | |
| import { logger } from '@/lib/logger' | |
| import { validateBody, spawnAgentSchema } from '@/lib/validation' | |
| import { scanForInjection } from '@/lib/injection-guard' | |
| import { logAuditEvent } from '@/lib/db' | |
| function getPreferredToolsProfile(): string { | |
| return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding' | |
| } | |
| export async function POST(request: NextRequest) { | |
| const auth = requireRole(request, 'operator') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| const rateCheck = heavyLimiter(request) | |
| if (rateCheck) return rateCheck | |
| try { | |
| const result = await validateBody(request, spawnAgentSchema) | |
| if ('error' in result) return result.error | |
| const { task, model, label, timeoutSeconds } = result.data | |
| // Scan the task prompt and label for injection before sending to an agent | |
| const fieldsToScan = [ | |
| { name: 'task', value: task }, | |
| ...(label ? [{ name: 'label', value: label }] : []), | |
| ] | |
| for (const field of fieldsToScan) { | |
| const injectionReport = scanForInjection(field.value, { context: 'prompt' }) | |
| if (!injectionReport.safe) { | |
| const criticals = injectionReport.matches.filter(m => m.severity === 'critical') | |
| if (criticals.length > 0) { | |
| logger.warn({ field: field.name, rules: criticals.map(m => m.rule) }, `Blocked spawn: injection detected in ${field.name}`) | |
| return NextResponse.json( | |
| { error: `${field.name} blocked: potentially unsafe content detected`, injection: criticals.map(m => ({ rule: m.rule, description: m.description })) }, | |
| { status: 422 } | |
| ) | |
| } | |
| } | |
| } | |
| const timeout = timeoutSeconds | |
| // Generate spawn ID | |
| const spawnId = `spawn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` | |
| // Construct the spawn command | |
| // Using OpenClaw's sessions_spawn function via clawdbot CLI | |
| const spawnPayload = { | |
| task, | |
| model, | |
| label, | |
| runTimeoutSeconds: timeout, | |
| tools: { | |
| profile: getPreferredToolsProfile(), | |
| }, | |
| } | |
| try { | |
| // Call gateway sessions_spawn directly. Try with tools.profile first, | |
| // fall back without it for older gateways that don't support the field. | |
| let result: any | |
| let compatibilityFallbackUsed = false | |
| try { | |
| result = await callOpenClawGateway('sessions_spawn', spawnPayload, 15_000) | |
| } catch (firstError: any) { | |
| const rawErr = String(firstError?.message || '').toLowerCase() | |
| const isToolsSchemaError = | |
| (rawErr.includes('unknown field') || rawErr.includes('unknown key') || rawErr.includes('invalid argument')) && | |
| (rawErr.includes('tools') || rawErr.includes('profile')) | |
| if (!isToolsSchemaError) throw firstError | |
| const fallbackPayload = { ...spawnPayload } | |
| delete (fallbackPayload as any).tools | |
| result = await callOpenClawGateway('sessions_spawn', fallbackPayload, 15_000) | |
| compatibilityFallbackUsed = true | |
| } | |
| const sessionInfo = result?.sessionId || result?.session_id || null | |
| const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' | |
| logAuditEvent({ | |
| action: 'agent_spawn', | |
| actor: auth.user.username, | |
| actor_id: auth.user.id, | |
| detail: { | |
| spawnId, | |
| model, | |
| label, | |
| task_summary: task.length > 120 ? task.slice(0, 120) + '...' : task, | |
| toolsProfile: getPreferredToolsProfile(), | |
| compatibilityFallbackUsed, | |
| }, | |
| ip_address: ipAddress, | |
| }) | |
| return NextResponse.json({ | |
| success: true, | |
| spawnId, | |
| sessionInfo, | |
| task, | |
| model, | |
| label, | |
| timeoutSeconds: timeout, | |
| createdAt: Date.now(), | |
| result, | |
| compatibility: { | |
| toolsProfile: getPreferredToolsProfile(), | |
| fallbackUsed: compatibilityFallbackUsed, | |
| }, | |
| }) | |
| } catch (execError: any) { | |
| logger.error({ err: execError }, 'Spawn execution error') | |
| return NextResponse.json({ | |
| success: false, | |
| spawnId, | |
| error: execError.message || 'Failed to spawn agent', | |
| task, | |
| model, | |
| label, | |
| timeoutSeconds: timeout, | |
| createdAt: Date.now() | |
| }, { status: 500 }) | |
| } | |
| } catch (error) { | |
| logger.error({ err: error }, 'Spawn API error') | |
| return NextResponse.json( | |
| { error: 'Internal server error' }, | |
| { status: 500 } | |
| ) | |
| } | |
| } | |
| // Get spawn history | |
| export async function GET(request: NextRequest) { | |
| const auth = requireRole(request, 'viewer') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| const rateCheck = heavyLimiter(request) | |
| if (rateCheck) return rateCheck | |
| try { | |
| const { searchParams } = new URL(request.url) | |
| const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200) | |
| // In a real implementation, you'd store spawn history in a database | |
| // For now, we'll try to read recent spawn activity from logs | |
| try { | |
| if (!config.logsDir) { | |
| return NextResponse.json({ history: [] }) | |
| } | |
| const files = await readdir(config.logsDir) | |
| const logFiles = await Promise.all( | |
| files | |
| .filter((file) => file.endsWith('.log')) | |
| .map(async (file) => { | |
| const fullPath = join(config.logsDir, file) | |
| const stats = await stat(fullPath) | |
| return { file, fullPath, mtime: stats.mtime.getTime() } | |
| }) | |
| ) | |
| const recentLogs = logFiles | |
| .sort((a, b) => b.mtime - a.mtime) | |
| .slice(0, 5) | |
| const lines: string[] = [] | |
| for (const log of recentLogs) { | |
| const content = await readFile(log.fullPath, 'utf-8') | |
| const matched = content | |
| .split('\n') | |
| .filter((line) => line.includes('sessions_spawn')) | |
| lines.push(...matched) | |
| } | |
| const spawnHistory = lines | |
| .slice(-limit) | |
| .map((line, index) => { | |
| try { | |
| const timestampMatch = line.match( | |
| /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/ | |
| ) | |
| const modelMatch = line.match(/model[:\s]+"([^"]+)"/) | |
| const taskMatch = line.match(/task[:\s]+"([^"]+)"/) | |
| return { | |
| id: `history-${Date.now()}-${index}`, | |
| timestamp: timestampMatch | |
| ? new Date(timestampMatch[1]).getTime() | |
| : Date.now(), | |
| model: modelMatch ? modelMatch[1] : 'unknown', | |
| task: taskMatch ? taskMatch[1] : 'unknown', | |
| status: 'completed', | |
| line: line.trim() | |
| } | |
| } catch (parseError) { | |
| return null | |
| } | |
| }) | |
| .filter(Boolean) | |
| return NextResponse.json({ history: spawnHistory }) | |
| } catch (logError) { | |
| // If we can't read logs, return empty history | |
| return NextResponse.json({ history: [] }) | |
| } | |
| } catch (error) { | |
| logger.error({ err: error }, 'Spawn history API error') | |
| return NextResponse.json( | |
| { error: 'Internal server error' }, | |
| { status: 500 } | |
| ) | |
| } | |
| } | |