HonzysClawdbot
fix(gateway): resolve Docker connectivity — 404 endpoints, EROFS write, missing OPENCLAW_HOME (#343)
303c344 unverified
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { config } from '@/lib/config'
import { logger } from '@/lib/logger'
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
const GATEWAY_TIMEOUT = 5000
/** Probe the gateway HTTP /health endpoint to check reachability. */
async function isGatewayReachable(): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT)
try {
const res = await fetch(
`http://${config.gatewayHost}:${config.gatewayPort}/health`,
{ signal: controller.signal },
)
return res.ok
} catch {
return false
} finally {
clearTimeout(timeout)
}
}
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 action = request.nextUrl.searchParams.get('action') || 'list'
if (action === 'list') {
try {
const connected = await isGatewayReachable()
if (!connected) {
return NextResponse.json({ nodes: [], connected: false })
}
try {
const data = await callOpenClawGateway<{ nodes?: unknown[] }>('node.list', {}, GATEWAY_TIMEOUT)
return NextResponse.json({ nodes: data?.nodes ?? [], connected: true })
} catch (rpcErr) {
// Gateway is reachable but openclaw CLI unavailable (e.g. Docker) or
// node.list not supported — return connected=true with empty node list
logger.warn({ err: rpcErr }, 'node.list RPC failed, returning empty node list')
return NextResponse.json({ nodes: [], connected: true })
}
} catch (err) {
logger.warn({ err }, 'Gateway unreachable for node listing')
return NextResponse.json({ nodes: [], connected: false })
}
}
if (action === 'devices') {
try {
const connected = await isGatewayReachable()
if (!connected) {
return NextResponse.json({ devices: [] })
}
try {
const data = await callOpenClawGateway<{ devices?: unknown[] }>(
'device.pair.list',
{},
GATEWAY_TIMEOUT,
)
return NextResponse.json({ devices: data?.devices ?? [] })
} catch (rpcErr) {
logger.warn({ err: rpcErr }, 'device.pair.list RPC failed, returning empty device list')
return NextResponse.json({ devices: [] })
}
} catch (err) {
logger.warn({ err }, 'Gateway unreachable for device listing')
return NextResponse.json({ devices: [] })
}
}
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 })
}
const VALID_DEVICE_ACTIONS = ['approve', 'reject', 'rotate-token', 'revoke-token'] as const
type DeviceAction = (typeof VALID_DEVICE_ACTIONS)[number]
/** Map UI action names to gateway RPC method names and their required param keys. */
const ACTION_RPC_MAP: Record<DeviceAction, { method: string; paramKey: 'requestId' | 'deviceId' }> = {
'approve': { method: 'device.pair.approve', paramKey: 'requestId' },
'reject': { method: 'device.pair.reject', paramKey: 'requestId' },
'rotate-token': { method: 'device.token.rotate', paramKey: 'deviceId' },
'revoke-token': { method: 'device.token.revoke', paramKey: 'deviceId' },
}
/**
* POST /api/nodes - Device management actions
* Body: { action: DeviceAction, requestId?: string, deviceId?: string, role?: string, scopes?: 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: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const action = body.action as string
if (!action || !VALID_DEVICE_ACTIONS.includes(action as DeviceAction)) {
return NextResponse.json(
{ error: `Invalid action. Must be one of: ${VALID_DEVICE_ACTIONS.join(', ')}` },
{ status: 400 },
)
}
const spec = ACTION_RPC_MAP[action as DeviceAction]
// Validate required param
const id = body[spec.paramKey] as string | undefined
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: `Missing required field: ${spec.paramKey}` }, { status: 400 })
}
// Build RPC params
const params: Record<string, unknown> = { [spec.paramKey]: id }
if ((action === 'rotate-token' || action === 'revoke-token') && body.role) {
params.role = body.role
}
if (action === 'rotate-token' && Array.isArray(body.scopes)) {
params.scopes = body.scopes
}
try {
const result = await callOpenClawGateway(spec.method, params, GATEWAY_TIMEOUT)
return NextResponse.json(result)
} catch (err: unknown) {
logger.error({ err }, 'Gateway device action failed')
return NextResponse.json({ error: 'Gateway device action failed' }, { status: 502 })
}
}