fix: add OpenClaw 3.2 compatibility for spawn and gateway health
This commit is contained in:
parent
6a49751599
commit
0e01f5d4b3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Reference in New Issue