From 0e01f5d4b315218f5826c50f29f73905f0d882c5 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Wed, 4 Mar 2026 21:59:20 +0700 Subject: [PATCH] fix: add OpenClaw 3.2 compatibility for spawn and gateway health --- .env.example | 5 ++ README.md | 2 + src/app/api/gateways/health/route.ts | 30 +++++++++++ src/app/api/spawn/route.ts | 50 ++++++++++++++++--- src/components/panels/multi-gateway-panel.tsx | 35 ++++++++++++- src/lib/websocket.ts | 3 +- 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index f58878d..9da5f96 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index ca9cec1..5b3cdeb 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index 6297183..1025b36 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -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) diff --git a/src/app/api/spawn/route.ts b/src/app/api/spawn/route.ts index 1119d12..cfe2951 100644 --- a/src/app/api/spawn/route.ts +++ b/src/app/api/spawn/route.ts @@ -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) { + 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) { diff --git a/src/components/panels/multi-gateway-panel.tsx b/src/components/panels/multi-gateway-panel.tsx index 9218663..b3a9bfa 100644 --- a/src/components/panels/multi-gateway-panel.tsx +++ b/src/components/panels/multi-gateway-panel.tsx @@ -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([]) const [directConnections, setDirectConnections] = useState([]) const [loading, setLoading] = useState(true) const [showAdd, setShowAdd] = useState(false) const [probing, setProbing] = useState(null) + const [healthByGatewayId, setHealthByGatewayId] = useState>(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() + 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() { 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 (
Latency: {gateway.latency}ms} Last: {lastSeen}
+ {health?.gateway_version && ( +
+ Gateway version: {health.gateway_version} +
+ )} + {compatibilityWarning && ( +
+ {compatibilityWarning} +
+ )}