import { NextRequest, NextResponse } from 'next/server' import { createHash } from 'node:crypto' import { requireRole } from '@/lib/auth' import { config } from '@/lib/config' import { logger } from '@/lib/logger' import path from 'node:path' function gatewayUrl(p: string): string { return `http://${config.gatewayHost}:${config.gatewayPort}${p}` } function execApprovalsPath(): string { return path.join(config.openclawHome, 'exec-approvals.json') } function computeHash(raw: string): string { return createHash('sha256').update(raw, 'utf8').digest('hex') } /** * GET /api/exec-approvals - Fetch pending execution approval requests * GET /api/exec-approvals?action=allowlist - Fetch per-agent allowlists */ export async function GET(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const action = request.nextUrl.searchParams.get('action') if (action === 'allowlist') { return getAllowlist() } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) try { const res = await fetch(gatewayUrl('/api/exec-approvals'), { signal: controller.signal, headers: { 'Accept': 'application/json' }, }) clearTimeout(timeout) if (!res.ok) { logger.warn({ status: res.status }, 'Gateway exec-approvals endpoint returned error') return NextResponse.json({ approvals: [] }) } const data = await res.json() return NextResponse.json(data) } catch (err: any) { clearTimeout(timeout) if (err.name === 'AbortError') { logger.warn('Gateway exec-approvals request timed out') } else { logger.warn({ err }, 'Gateway exec-approvals unreachable') } return NextResponse.json({ approvals: [] }) } } async function getAllowlist(): Promise { const filePath = execApprovalsPath() try { const { readFile } = require('fs/promises') const raw = await readFile(filePath, 'utf-8') const parsed = JSON.parse(raw) const agents: Record = {} if (parsed?.agents && typeof parsed.agents === 'object') { for (const [agentId, agentConfig] of Object.entries(parsed.agents)) { const cfg = agentConfig as any if (Array.isArray(cfg?.allowlist)) { agents[agentId] = cfg.allowlist.map((e: any) => ({ pattern: String(e?.pattern ?? '') })) } else { agents[agentId] = [] } } } return NextResponse.json({ agents, hash: computeHash(raw) }) } catch (err: any) { if (err.code === 'ENOENT') { return NextResponse.json({ agents: {}, hash: computeHash('') }) } logger.warn({ err }, 'Failed to read exec-approvals config') return NextResponse.json({ error: `Failed to read config: ${err.message}` }, { status: 500 }) } } /** * PUT /api/exec-approvals - Save allowlist changes * Body: { agents: Record, hash?: string } */ export async function PUT(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) let body: { agents: Record; hash?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } if (!body.agents || typeof body.agents !== 'object') { return NextResponse.json({ error: 'Missing required field: agents' }, { status: 400 }) } const filePath = execApprovalsPath() try { const { readFile, writeFile, mkdir } = require('fs/promises') const { existsSync } = require('fs') let parsed: any = { version: 1, agents: {} } try { const raw = await readFile(filePath, 'utf-8') parsed = JSON.parse(raw) if (body.hash) { const serverHash = computeHash(raw) if (body.hash !== serverHash) { return NextResponse.json( { error: 'Config has been modified. Please reload and try again.', code: 'CONFLICT' }, { status: 409 }, ) } } } catch (err: any) { if (err.code !== 'ENOENT') throw err } if (!parsed.agents) parsed.agents = {} for (const [agentId, patterns] of Object.entries(body.agents)) { if (!parsed.agents[agentId]) parsed.agents[agentId] = {} if (patterns.length === 0) { delete parsed.agents[agentId].allowlist } else { parsed.agents[agentId].allowlist = patterns.map((p: { pattern: string }) => ({ pattern: String(p.pattern ?? ''), })) } } const dir = path.dirname(filePath) if (!existsSync(dir)) { await mkdir(dir, { recursive: true }) } const newRaw = JSON.stringify(parsed, null, 2) + '\n' await writeFile(filePath, newRaw, { mode: 0o600 }) return NextResponse.json({ ok: true, hash: computeHash(newRaw) }) } catch (err: any) { logger.error({ err }, 'Failed to save exec-approvals config') return NextResponse.json({ error: `Failed to save: ${err.message}` }, { status: 500 }) } } /** * POST /api/exec-approvals - Respond to an execution approval request * Body: { id: string, action: 'approve' | 'deny' | 'always_allow', reason?: string } */ export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) let body: { id: string; action: string; reason?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } if (!body.id || typeof body.id !== 'string') { return NextResponse.json({ error: 'Missing required field: id' }, { status: 400 }) } const validActions = ['approve', 'deny', 'always_allow'] if (!validActions.includes(body.action)) { return NextResponse.json({ error: `Invalid action. Must be one of: ${validActions.join(', ')}` }, { status: 400 }) } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) try { const res = await fetch(gatewayUrl('/api/exec-approvals/respond'), { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: body.id, action: body.action, reason: body.reason, }), }) clearTimeout(timeout) const data = await res.json() return NextResponse.json(data, { status: res.status }) } catch (err: any) { clearTimeout(timeout) if (err.name === 'AbortError') { logger.error('Gateway exec-approvals respond request timed out') return NextResponse.json({ error: 'Gateway request timed out' }, { status: 504 }) } logger.error({ err }, 'Gateway exec-approvals respond failed') return NextResponse.json({ error: 'Gateway unreachable' }, { status: 502 }) } }