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 }
)
}
}