Spaces:
Runtime error
Runtime error
nyk
feat: audit hardening, webhook retry, and local Claude session tracking (#68)
959f8d7 unverified | import { NextRequest, NextResponse } from 'next/server' | |
| import { readFile, readdir, stat } from 'fs/promises' | |
| import { join } from 'path' | |
| import { config } from '@/lib/config' | |
| import { requireRole } from '@/lib/auth' | |
| import { readLimiter, mutationLimiter } from '@/lib/rate-limit' | |
| import { logger } from '@/lib/logger' | |
| const LOGS_PATH = config.logsDir | |
| interface LogEntry { | |
| id: string | |
| timestamp: number | |
| level: 'info' | 'warn' | 'error' | 'debug' | |
| source: string | |
| session?: string | |
| message: string | |
| data?: any | |
| } | |
| /** | |
| * Parse a log line from various OpenClaw log formats: | |
| * - Pipe-delimited: "2026-02-09T17:00:01+01:00|MONITOR|Consistency check completed" | |
| * - Simple text: "done report=/path/to/.openclaw/workspace-<agent>/reports/..." | |
| * - JSON structured: { timestamp, level, message, ... } | |
| * - Gateway journal: "2026-02-09T18:05:49+01:00 host openclaw[1737454]: ..." | |
| */ | |
| function parseLogLine(line: string, source: string): LogEntry | null { | |
| if (!line.trim()) return null | |
| try { | |
| // Try JSON first | |
| if (line.startsWith('{')) { | |
| const parsed = JSON.parse(line) | |
| return { | |
| id: `${source}-${parsed.timestamp || Date.now()}-${Math.random().toString(36).slice(2, 8)}`, | |
| timestamp: parsed.timestamp || Date.now(), | |
| level: parsed.level || 'info', | |
| source: parsed.source || source, | |
| session: parsed.session, | |
| message: parsed.message || line, | |
| data: parsed.data, | |
| } | |
| } | |
| // Pipe-delimited format: "TIMESTAMP|LEVEL|MESSAGE" | |
| const pipeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:]+[^\|]*)\|([^\|]+)\|(.+)$/) | |
| if (pipeMatch) { | |
| const ts = new Date(pipeMatch[1]).getTime() | |
| const levelRaw = pipeMatch[2].trim().toLowerCase() | |
| let level: LogEntry['level'] = 'info' | |
| if (levelRaw === 'error' || levelRaw === 'err') level = 'error' | |
| else if (levelRaw === 'warn' || levelRaw === 'warning') level = 'warn' | |
| else if (levelRaw === 'debug') level = 'debug' | |
| else if (levelRaw === 'ok' || levelRaw === 'monitor' || levelRaw === 'info') level = 'info' | |
| return { | |
| id: `${source}-${ts}-${Math.random().toString(36).slice(2, 8)}`, | |
| timestamp: isNaN(ts) ? Date.now() : ts, | |
| level, | |
| source, | |
| message: pipeMatch[3].trim(), | |
| } | |
| } | |
| // Gateway journal format: "TIMESTAMP HOSTNAME openclaw[PID]: MESSAGE" | |
| const journalMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:]+[^\s]*)\s+\S+\s+\S+:\s+(.+)$/) | |
| if (journalMatch) { | |
| const ts = new Date(journalMatch[1]).getTime() | |
| const msg = journalMatch[2] | |
| let level: LogEntry['level'] = 'info' | |
| if (msg.includes('error') || msg.includes('Error') || msg.includes('ERR')) level = 'error' | |
| else if (msg.includes('warn') || msg.includes('WARN')) level = 'warn' | |
| else if (msg.includes('debug') || msg.includes('DEBUG')) level = 'debug' | |
| return { | |
| id: `${source}-${ts}-${Math.random().toString(36).slice(2, 8)}`, | |
| timestamp: isNaN(ts) ? Date.now() : ts, | |
| level, | |
| source, | |
| message: msg, | |
| } | |
| } | |
| // ISO timestamp prefix: "2026-02-09T... [LEVEL] message" | |
| const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}[\sT]\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:?\d{2})?)/) | |
| const levelMatch = line.match(/\[(ERROR|WARN|INFO|DEBUG)\]/i) || line.match(/(ERROR|WARN|INFO|DEBUG):/i) | |
| let timestamp = Date.now() | |
| if (isoMatch) { | |
| const t = new Date(isoMatch[1]).getTime() | |
| if (!isNaN(t)) timestamp = t | |
| } | |
| let level: LogEntry['level'] = 'info' | |
| if (levelMatch) { | |
| level = levelMatch[1].toLowerCase() as LogEntry['level'] | |
| } else if (line.toLowerCase().includes('error')) { | |
| level = 'error' | |
| } else if (line.toLowerCase().includes('warn')) { | |
| level = 'warn' | |
| } | |
| return { | |
| id: `${source}-${timestamp}-${Math.random().toString(36).slice(2, 8)}`, | |
| timestamp, | |
| level, | |
| source, | |
| message: line.trim(), | |
| } | |
| } catch { | |
| return { | |
| id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, | |
| timestamp: Date.now(), | |
| level: 'info', | |
| source, | |
| message: line.trim(), | |
| } | |
| } | |
| } | |
| /** | |
| * Discover all log files in the OpenClaw logs directory. | |
| * Scans both top-level and automation/ subdirectory. | |
| */ | |
| async function discoverLogFiles(): Promise<Array<{ path: string; source: string }>> { | |
| const files: Array<{ path: string; source: string }> = [] | |
| if (!LOGS_PATH) return files | |
| try { | |
| const entries = await readdir(LOGS_PATH, { withFileTypes: true }) | |
| for (const entry of entries) { | |
| if (entry.isFile() && entry.name.endsWith('.log')) { | |
| files.push({ | |
| path: join(LOGS_PATH, entry.name), | |
| source: entry.name.replace('.log', ''), | |
| }) | |
| } else if (entry.isDirectory()) { | |
| // Scan subdirectories (e.g., automation/) | |
| try { | |
| const subEntries = await readdir(join(LOGS_PATH, entry.name)) | |
| for (const subFile of subEntries) { | |
| if (subFile.endsWith('.log')) { | |
| files.push({ | |
| path: join(LOGS_PATH, entry.name, subFile), | |
| source: `${entry.name}/${subFile.replace('.log', '')}`, | |
| }) | |
| } | |
| } | |
| } catch { | |
| // Skip unreadable subdirectories | |
| } | |
| } | |
| } | |
| } catch { | |
| // Logs directory doesn't exist or isn't readable | |
| } | |
| return files | |
| } | |
| async function readLogFile(filePath: string, source: string, maxLines: number): Promise<LogEntry[]> { | |
| try { | |
| const content = await readFile(filePath, 'utf-8') | |
| const lines = content.split('\n').slice(-maxLines) | |
| const entries: LogEntry[] = [] | |
| for (const line of lines) { | |
| const entry = parseLogLine(line, source) | |
| if (entry) entries.push(entry) | |
| } | |
| return entries | |
| } catch { | |
| return [] | |
| } | |
| } | |
| 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 = readLimiter(request) | |
| if (rateCheck) return rateCheck | |
| try { | |
| const { searchParams } = new URL(request.url) | |
| const action = searchParams.get('action') || 'recent' | |
| const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 200) | |
| const level = searchParams.get('level') | |
| const session = searchParams.get('session') | |
| const search = searchParams.get('search') | |
| const source = searchParams.get('source') | |
| if (action === 'recent') { | |
| const logFiles = await discoverLogFiles() | |
| let logs: LogEntry[] = [] | |
| // Read from all discovered log files | |
| for (const file of logFiles) { | |
| if (source && file.source !== source) continue | |
| const entries = await readLogFile(file.path, file.source, 200) | |
| logs.push(...entries) | |
| } | |
| // Sort newest first | |
| logs.sort((a, b) => b.timestamp - a.timestamp) | |
| // Apply filters | |
| if (level) { | |
| logs = logs.filter(log => log.level === level) | |
| } | |
| if (session) { | |
| logs = logs.filter(log => log.session?.includes(session)) | |
| } | |
| if (search) { | |
| const searchLower = search.toLowerCase() | |
| logs = logs.filter(log => | |
| log.message.toLowerCase().includes(searchLower) || | |
| log.source.toLowerCase().includes(searchLower) | |
| ) | |
| } | |
| logs = logs.slice(0, limit) | |
| return NextResponse.json({ logs }) | |
| } | |
| if (action === 'sources') { | |
| const logFiles = await discoverLogFiles() | |
| const sources = logFiles.map(f => f.source) | |
| return NextResponse.json({ sources }) | |
| } | |
| if (action === 'tail') { | |
| const sinceTimestamp = parseInt(searchParams.get('since') || '0') | |
| const logFiles = await discoverLogFiles() | |
| let logs: LogEntry[] = [] | |
| for (const file of logFiles) { | |
| if (source && file.source !== source) continue | |
| const entries = await readLogFile(file.path, file.source, 50) | |
| logs.push(...entries.filter(e => e.timestamp > sinceTimestamp)) | |
| } | |
| logs.sort((a, b) => b.timestamp - a.timestamp) | |
| logs = logs.slice(0, limit) | |
| return NextResponse.json({ logs }) | |
| } | |
| return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) | |
| } catch (error) { | |
| logger.error({ err: error }, 'Logs API error') | |
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | |
| } | |
| } | |
| 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 = mutationLimiter(request) | |
| if (rateCheck) return rateCheck | |
| try { | |
| const { action, message, level, source: customSource, session } = await request.json() | |
| if (action === 'add') { | |
| if (!message) { | |
| return NextResponse.json({ error: 'Message required' }, { status: 400 }) | |
| } | |
| const logEntry: LogEntry = { | |
| id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, | |
| timestamp: Date.now(), | |
| level: level || 'info', | |
| source: customSource || 'mission-control', | |
| session, | |
| message, | |
| data: null, | |
| } | |
| return NextResponse.json({ success: true, entry: logEntry }) | |
| } | |
| return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) | |
| } catch (error) { | |
| logger.error({ err: error }, 'Logs API error') | |
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | |
| } | |
| } | |