nyk
feat: audit hardening, webhook retry, and local Claude session tracking (#68)
959f8d7 unverified
Raw
History Blame Contribute Delete
9.53 kB
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 })
}
}