fix: add OpenClaw 3.2 compatibility for spawn and gateway health

This commit is contained in:
Nyk 2026-03-04 21:59:20 +07:00
parent 6a49751599
commit 0e01f5d4b3
6 changed files with 115 additions and 10 deletions

View File

@ -54,6 +54,9 @@ OPENCLAW_GATEWAY_HOST=127.0.0.1
OPENCLAW_GATEWAY_PORT=18789
# Optional: token used by server-side gateway calls
OPENCLAW_GATEWAY_TOKEN=
# Tools profile used when Mission Control spawns sessions via sessions_spawn.
# OpenClaw 2026.3.2+ defaults to "messaging" if omitted.
OPENCLAW_TOOLS_PROFILE=coding
# Frontend env vars (NEXT_PUBLIC_ prefix = available in browser)
NEXT_PUBLIC_GATEWAY_HOST=
@ -61,6 +64,8 @@ NEXT_PUBLIC_GATEWAY_PORT=18789
NEXT_PUBLIC_GATEWAY_PROTOCOL=
NEXT_PUBLIC_GATEWAY_URL=
# NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
# Gateway client id used in websocket handshake (role=operator UI client).
NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui
# === Data Paths (all optional, defaults to .data/ in project root) ===
# MISSION_CONTROL_DATA_DIR=.data

View File

@ -346,7 +346,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
| `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
| `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
| `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
| `OPENCLAW_TOOLS_PROFILE` | No | Tools profile for `sessions_spawn` (recommended: `coding`) |
| `NEXT_PUBLIC_GATEWAY_TOKEN` | No | Browser-side gateway auth token (must use `NEXT_PUBLIC_` prefix) |
| `NEXT_PUBLIC_GATEWAY_CLIENT_ID` | No | Gateway UI client ID for websocket handshake (default: `control-ui`) |
| `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) |
| `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) |
| `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |

View File

@ -19,9 +19,33 @@ interface HealthResult {
latency: number | null
agents: string[]
sessions_count: number
gateway_version?: string | null
compatibility_warning?: string
error?: string
}
function parseGatewayVersion(res: Response): string | null {
const direct = res.headers.get('x-openclaw-version') || res.headers.get('x-clawdbot-version')
if (direct) return direct.trim()
const server = res.headers.get('server') || ''
const m = server.match(/(\d{4}\.\d+\.\d+)/)
return m?.[1] || null
}
function hasOpenClaw32ToolsProfileRisk(version: string | null): boolean {
if (!version) return false
const m = version.match(/^(\d{4})\.(\d+)\.(\d+)/)
if (!m) return false
const year = Number(m[1])
const major = Number(m[2])
const minor = Number(m[3])
if (year > 2026) return true
if (year < 2026) return false
if (major > 3) return true
if (major < 3) return false
return minor >= 2
}
function isBlockedUrl(urlStr: string): boolean {
try {
const url = new URL(urlStr)
@ -77,6 +101,10 @@ export async function POST(request: NextRequest) {
const latency = Date.now() - start
const status = res.ok ? "online" : "error"
const gatewayVersion = parseGatewayVersion(res)
const compatibilityWarning = hasOpenClaw32ToolsProfileRisk(gatewayVersion)
? 'OpenClaw 2026.3.2+ defaults tools.profile=messaging; Mission Control should enforce coding profile when spawning.'
: undefined
updateOnlineStmt.run(status, latency, gw.id)
@ -87,6 +115,8 @@ export async function POST(request: NextRequest) {
latency,
agents: [],
sessions_count: 0,
gateway_version: gatewayVersion,
compatibility_warning: compatibilityWarning,
})
} catch (err: any) {
updateOfflineStmt.run("offline", gw.id)

View File

@ -8,6 +8,15 @@ import { heavyLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
import { validateBody, spawnAgentSchema } from '@/lib/validation'
function getPreferredToolsProfile(): string {
return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding'
}
async function runSpawnWithCompatibility(spawnPayload: Record<string, unknown>) {
const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})`
return runClawdbot(['-c', commandArg], { timeoutMs: 10000 })
}
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
@ -31,15 +40,38 @@ export async function POST(request: NextRequest) {
task,
model,
label,
runTimeoutSeconds: timeout
runTimeoutSeconds: timeout,
tools: {
profile: getPreferredToolsProfile(),
},
}
const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})`
try {
// Execute the spawn command
const { stdout, stderr } = await runClawdbot(['-c', commandArg], {
timeoutMs: 10000
})
// Execute the spawn command (OpenClaw 2026.3.2+ defaults tools.profile to messaging).
let stdout = ''
let stderr = ''
let compatibilityFallbackUsed = false
try {
const result = await runSpawnWithCompatibility(spawnPayload)
stdout = result.stdout
stderr = result.stderr
} catch (firstError: any) {
const rawErr = String(firstError?.stderr || firstError?.message || '').toLowerCase()
const likelySchemaMismatch =
rawErr.includes('unknown field') ||
rawErr.includes('unknown key') ||
rawErr.includes('invalid argument') ||
rawErr.includes('tools') ||
rawErr.includes('profile')
if (!likelySchemaMismatch) throw firstError
const fallbackPayload = { ...spawnPayload }
delete (fallbackPayload as any).tools
const fallback = await runSpawnWithCompatibility(fallbackPayload)
stdout = fallback.stdout
stderr = fallback.stderr
compatibilityFallbackUsed = true
}
// Parse the response to extract session info
let sessionInfo = null
@ -63,7 +95,11 @@ export async function POST(request: NextRequest) {
timeoutSeconds: timeout,
createdAt: Date.now(),
stdout: stdout.trim(),
stderr: stderr.trim()
stderr: stderr.trim(),
compatibility: {
toolsProfile: getPreferredToolsProfile(),
fallbackUsed: compatibilityFallbackUsed,
},
})
} catch (execError: any) {

View File

@ -35,12 +35,23 @@ interface DirectConnection {
agent_role: string
}
interface GatewayHealthProbe {
id: number
name: string
status: 'online' | 'offline' | 'error'
latency: number | null
gateway_version?: string | null
compatibility_warning?: string
error?: string
}
export function MultiGatewayPanel() {
const [gateways, setGateways] = useState<Gateway[]>([])
const [directConnections, setDirectConnections] = useState<DirectConnection[]>([])
const [loading, setLoading] = useState(true)
const [showAdd, setShowAdd] = useState(false)
const [probing, setProbing] = useState<number | null>(null)
const [healthByGatewayId, setHealthByGatewayId] = useState<Map<number, GatewayHealthProbe>>(new Map())
const { connection } = useMissionControl()
const { connect } = useWebSocket()
@ -89,7 +100,14 @@ export function MultiGatewayPanel() {
const probeAll = async () => {
try {
await fetch("/api/gateways/health", { method: "POST" })
const res = await fetch("/api/gateways/health", { method: "POST" })
const data = await res.json().catch(() => ({}))
const rows = Array.isArray(data?.results) ? data.results as GatewayHealthProbe[] : []
const mapped = new Map<number, GatewayHealthProbe>()
for (const row of rows) {
if (typeof row?.id === 'number') mapped.set(row.id, row)
}
setHealthByGatewayId(mapped)
} catch { /* ignore */ }
fetchGateways()
}
@ -172,6 +190,7 @@ export function MultiGatewayPanel() {
<GatewayCard
key={gw.id}
gateway={gw}
health={healthByGatewayId.get(gw.id)}
isProbing={probing === gw.id}
isCurrentlyConnected={connection.url?.includes(`:${gw.port}`) ?? false}
onSetPrimary={() => setPrimary(gw)}
@ -250,8 +269,9 @@ export function MultiGatewayPanel() {
)
}
function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: {
function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: {
gateway: Gateway
health?: GatewayHealthProbe
isProbing: boolean
isCurrentlyConnected: boolean
onSetPrimary: () => void
@ -269,6 +289,7 @@ function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, o
const lastSeen = gateway.last_seen
? new Date(gateway.last_seen * 1000).toLocaleString()
: 'Never probed'
const compatibilityWarning = health?.compatibility_warning
return (
<div className={`bg-card border rounded-lg p-4 transition-smooth ${
@ -296,6 +317,16 @@ function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, o
{gateway.latency != null && <span>Latency: {gateway.latency}ms</span>}
<span>Last: {lastSeen}</span>
</div>
{health?.gateway_version && (
<div className="mt-1 text-2xs text-muted-foreground">
Gateway version: <span className="font-mono text-foreground/80">{health.gateway_version}</span>
</div>
)}
{compatibilityWarning && (
<div className="mt-1.5 text-2xs rounded border border-amber-500/30 bg-amber-500/10 text-amber-300 px-2 py-1">
{compatibilityWarning}
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
<button

View File

@ -13,6 +13,7 @@ import { APP_VERSION } from '@/lib/version'
// Gateway protocol version (v3 required by OpenClaw 2026.x)
const PROTOCOL_VERSION = 3
const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui'
// Heartbeat configuration
const PING_INTERVAL_MS = 30_000
@ -179,7 +180,7 @@ export function useWebSocket() {
const cachedToken = getCachedDeviceToken()
const clientId = 'gateway-client'
const clientId = DEFAULT_GATEWAY_CLIENT_ID
const clientMode = 'ui'
const role = 'operator'
const scopes = ['operator.admin']