Nyk commited on
Commit
e7f2128
·
1 Parent(s): 2aff45a

fix: add OpenClaw 3.2 compatibility for spawn and gateway health

Browse files
.env.example CHANGED
@@ -54,6 +54,9 @@ OPENCLAW_GATEWAY_HOST=127.0.0.1
54
  OPENCLAW_GATEWAY_PORT=18789
55
  # Optional: token used by server-side gateway calls
56
  OPENCLAW_GATEWAY_TOKEN=
 
 
 
57
 
58
  # Frontend env vars (NEXT_PUBLIC_ prefix = available in browser)
59
  NEXT_PUBLIC_GATEWAY_HOST=
@@ -61,6 +64,8 @@ NEXT_PUBLIC_GATEWAY_PORT=18789
61
  NEXT_PUBLIC_GATEWAY_PROTOCOL=
62
  NEXT_PUBLIC_GATEWAY_URL=
63
  # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
 
 
64
 
65
  # === Data Paths (all optional, defaults to .data/ in project root) ===
66
  # MISSION_CONTROL_DATA_DIR=.data
 
54
  OPENCLAW_GATEWAY_PORT=18789
55
  # Optional: token used by server-side gateway calls
56
  OPENCLAW_GATEWAY_TOKEN=
57
+ # Tools profile used when Mission Control spawns sessions via sessions_spawn.
58
+ # OpenClaw 2026.3.2+ defaults to "messaging" if omitted.
59
+ OPENCLAW_TOOLS_PROFILE=coding
60
 
61
  # Frontend env vars (NEXT_PUBLIC_ prefix = available in browser)
62
  NEXT_PUBLIC_GATEWAY_HOST=
 
64
  NEXT_PUBLIC_GATEWAY_PROTOCOL=
65
  NEXT_PUBLIC_GATEWAY_URL=
66
  # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
67
+ # Gateway client id used in websocket handshake (role=operator UI client).
68
+ NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui
69
 
70
  # === Data Paths (all optional, defaults to .data/ in project root) ===
71
  # MISSION_CONTROL_DATA_DIR=.data
README.md CHANGED
@@ -346,7 +346,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
346
  | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
347
  | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
348
  | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
 
349
  | `NEXT_PUBLIC_GATEWAY_TOKEN` | No | Browser-side gateway auth token (must use `NEXT_PUBLIC_` prefix) |
 
350
  | `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) |
351
  | `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) |
352
  | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
 
346
  | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
347
  | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
348
  | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
349
+ | `OPENCLAW_TOOLS_PROFILE` | No | Tools profile for `sessions_spawn` (recommended: `coding`) |
350
  | `NEXT_PUBLIC_GATEWAY_TOKEN` | No | Browser-side gateway auth token (must use `NEXT_PUBLIC_` prefix) |
351
+ | `NEXT_PUBLIC_GATEWAY_CLIENT_ID` | No | Gateway UI client ID for websocket handshake (default: `control-ui`) |
352
  | `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) |
353
  | `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) |
354
  | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
src/app/api/gateways/health/route.ts CHANGED
@@ -19,9 +19,33 @@ interface HealthResult {
19
  latency: number | null
20
  agents: string[]
21
  sessions_count: number
 
 
22
  error?: string
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  function isBlockedUrl(urlStr: string): boolean {
26
  try {
27
  const url = new URL(urlStr)
@@ -77,6 +101,10 @@ export async function POST(request: NextRequest) {
77
 
78
  const latency = Date.now() - start
79
  const status = res.ok ? "online" : "error"
 
 
 
 
80
 
81
  updateOnlineStmt.run(status, latency, gw.id)
82
 
@@ -87,6 +115,8 @@ export async function POST(request: NextRequest) {
87
  latency,
88
  agents: [],
89
  sessions_count: 0,
 
 
90
  })
91
  } catch (err: any) {
92
  updateOfflineStmt.run("offline", gw.id)
 
19
  latency: number | null
20
  agents: string[]
21
  sessions_count: number
22
+ gateway_version?: string | null
23
+ compatibility_warning?: string
24
  error?: string
25
  }
26
 
27
+ function parseGatewayVersion(res: Response): string | null {
28
+ const direct = res.headers.get('x-openclaw-version') || res.headers.get('x-clawdbot-version')
29
+ if (direct) return direct.trim()
30
+ const server = res.headers.get('server') || ''
31
+ const m = server.match(/(\d{4}\.\d+\.\d+)/)
32
+ return m?.[1] || null
33
+ }
34
+
35
+ function hasOpenClaw32ToolsProfileRisk(version: string | null): boolean {
36
+ if (!version) return false
37
+ const m = version.match(/^(\d{4})\.(\d+)\.(\d+)/)
38
+ if (!m) return false
39
+ const year = Number(m[1])
40
+ const major = Number(m[2])
41
+ const minor = Number(m[3])
42
+ if (year > 2026) return true
43
+ if (year < 2026) return false
44
+ if (major > 3) return true
45
+ if (major < 3) return false
46
+ return minor >= 2
47
+ }
48
+
49
  function isBlockedUrl(urlStr: string): boolean {
50
  try {
51
  const url = new URL(urlStr)
 
101
 
102
  const latency = Date.now() - start
103
  const status = res.ok ? "online" : "error"
104
+ const gatewayVersion = parseGatewayVersion(res)
105
+ const compatibilityWarning = hasOpenClaw32ToolsProfileRisk(gatewayVersion)
106
+ ? 'OpenClaw 2026.3.2+ defaults tools.profile=messaging; Mission Control should enforce coding profile when spawning.'
107
+ : undefined
108
 
109
  updateOnlineStmt.run(status, latency, gw.id)
110
 
 
115
  latency,
116
  agents: [],
117
  sessions_count: 0,
118
+ gateway_version: gatewayVersion,
119
+ compatibility_warning: compatibilityWarning,
120
  })
121
  } catch (err: any) {
122
  updateOfflineStmt.run("offline", gw.id)
src/app/api/spawn/route.ts CHANGED
@@ -8,6 +8,15 @@ import { heavyLimiter } from '@/lib/rate-limit'
8
  import { logger } from '@/lib/logger'
9
  import { validateBody, spawnAgentSchema } from '@/lib/validation'
10
 
 
 
 
 
 
 
 
 
 
11
  export async function POST(request: NextRequest) {
12
  const auth = requireRole(request, 'operator')
13
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
@@ -31,15 +40,38 @@ export async function POST(request: NextRequest) {
31
  task,
32
  model,
33
  label,
34
- runTimeoutSeconds: timeout
 
 
 
35
  }
36
- const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})`
37
 
38
  try {
39
- // Execute the spawn command
40
- const { stdout, stderr } = await runClawdbot(['-c', commandArg], {
41
- timeoutMs: 10000
42
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  // Parse the response to extract session info
45
  let sessionInfo = null
@@ -63,7 +95,11 @@ export async function POST(request: NextRequest) {
63
  timeoutSeconds: timeout,
64
  createdAt: Date.now(),
65
  stdout: stdout.trim(),
66
- stderr: stderr.trim()
 
 
 
 
67
  })
68
 
69
  } catch (execError: any) {
 
8
  import { logger } from '@/lib/logger'
9
  import { validateBody, spawnAgentSchema } from '@/lib/validation'
10
 
11
+ function getPreferredToolsProfile(): string {
12
+ return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding'
13
+ }
14
+
15
+ async function runSpawnWithCompatibility(spawnPayload: Record<string, unknown>) {
16
+ const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})`
17
+ return runClawdbot(['-c', commandArg], { timeoutMs: 10000 })
18
+ }
19
+
20
  export async function POST(request: NextRequest) {
21
  const auth = requireRole(request, 'operator')
22
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
 
40
  task,
41
  model,
42
  label,
43
+ runTimeoutSeconds: timeout,
44
+ tools: {
45
+ profile: getPreferredToolsProfile(),
46
+ },
47
  }
 
48
 
49
  try {
50
+ // Execute the spawn command (OpenClaw 2026.3.2+ defaults tools.profile to messaging).
51
+ let stdout = ''
52
+ let stderr = ''
53
+ let compatibilityFallbackUsed = false
54
+ try {
55
+ const result = await runSpawnWithCompatibility(spawnPayload)
56
+ stdout = result.stdout
57
+ stderr = result.stderr
58
+ } catch (firstError: any) {
59
+ const rawErr = String(firstError?.stderr || firstError?.message || '').toLowerCase()
60
+ const likelySchemaMismatch =
61
+ rawErr.includes('unknown field') ||
62
+ rawErr.includes('unknown key') ||
63
+ rawErr.includes('invalid argument') ||
64
+ rawErr.includes('tools') ||
65
+ rawErr.includes('profile')
66
+ if (!likelySchemaMismatch) throw firstError
67
+
68
+ const fallbackPayload = { ...spawnPayload }
69
+ delete (fallbackPayload as any).tools
70
+ const fallback = await runSpawnWithCompatibility(fallbackPayload)
71
+ stdout = fallback.stdout
72
+ stderr = fallback.stderr
73
+ compatibilityFallbackUsed = true
74
+ }
75
 
76
  // Parse the response to extract session info
77
  let sessionInfo = null
 
95
  timeoutSeconds: timeout,
96
  createdAt: Date.now(),
97
  stdout: stdout.trim(),
98
+ stderr: stderr.trim(),
99
+ compatibility: {
100
+ toolsProfile: getPreferredToolsProfile(),
101
+ fallbackUsed: compatibilityFallbackUsed,
102
+ },
103
  })
104
 
105
  } catch (execError: any) {
src/components/panels/multi-gateway-panel.tsx CHANGED
@@ -35,12 +35,23 @@ interface DirectConnection {
35
  agent_role: string
36
  }
37
 
 
 
 
 
 
 
 
 
 
 
38
  export function MultiGatewayPanel() {
39
  const [gateways, setGateways] = useState<Gateway[]>([])
40
  const [directConnections, setDirectConnections] = useState<DirectConnection[]>([])
41
  const [loading, setLoading] = useState(true)
42
  const [showAdd, setShowAdd] = useState(false)
43
  const [probing, setProbing] = useState<number | null>(null)
 
44
  const { connection } = useMissionControl()
45
  const { connect } = useWebSocket()
46
 
@@ -89,7 +100,14 @@ export function MultiGatewayPanel() {
89
 
90
  const probeAll = async () => {
91
  try {
92
- await fetch("/api/gateways/health", { method: "POST" })
 
 
 
 
 
 
 
93
  } catch { /* ignore */ }
94
  fetchGateways()
95
  }
@@ -172,6 +190,7 @@ export function MultiGatewayPanel() {
172
  <GatewayCard
173
  key={gw.id}
174
  gateway={gw}
 
175
  isProbing={probing === gw.id}
176
  isCurrentlyConnected={connection.url?.includes(`:${gw.port}`) ?? false}
177
  onSetPrimary={() => setPrimary(gw)}
@@ -250,8 +269,9 @@ export function MultiGatewayPanel() {
250
  )
251
  }
252
 
253
- function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: {
254
  gateway: Gateway
 
255
  isProbing: boolean
256
  isCurrentlyConnected: boolean
257
  onSetPrimary: () => void
@@ -269,6 +289,7 @@ function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, o
269
  const lastSeen = gateway.last_seen
270
  ? new Date(gateway.last_seen * 1000).toLocaleString()
271
  : 'Never probed'
 
272
 
273
  return (
274
  <div className={`bg-card border rounded-lg p-4 transition-smooth ${
@@ -296,6 +317,16 @@ function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, o
296
  {gateway.latency != null && <span>Latency: {gateway.latency}ms</span>}
297
  <span>Last: {lastSeen}</span>
298
  </div>
 
 
 
 
 
 
 
 
 
 
299
  </div>
300
  <div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
301
  <button
 
35
  agent_role: string
36
  }
37
 
38
+ interface GatewayHealthProbe {
39
+ id: number
40
+ name: string
41
+ status: 'online' | 'offline' | 'error'
42
+ latency: number | null
43
+ gateway_version?: string | null
44
+ compatibility_warning?: string
45
+ error?: string
46
+ }
47
+
48
  export function MultiGatewayPanel() {
49
  const [gateways, setGateways] = useState<Gateway[]>([])
50
  const [directConnections, setDirectConnections] = useState<DirectConnection[]>([])
51
  const [loading, setLoading] = useState(true)
52
  const [showAdd, setShowAdd] = useState(false)
53
  const [probing, setProbing] = useState<number | null>(null)
54
+ const [healthByGatewayId, setHealthByGatewayId] = useState<Map<number, GatewayHealthProbe>>(new Map())
55
  const { connection } = useMissionControl()
56
  const { connect } = useWebSocket()
57
 
 
100
 
101
  const probeAll = async () => {
102
  try {
103
+ const res = await fetch("/api/gateways/health", { method: "POST" })
104
+ const data = await res.json().catch(() => ({}))
105
+ const rows = Array.isArray(data?.results) ? data.results as GatewayHealthProbe[] : []
106
+ const mapped = new Map<number, GatewayHealthProbe>()
107
+ for (const row of rows) {
108
+ if (typeof row?.id === 'number') mapped.set(row.id, row)
109
+ }
110
+ setHealthByGatewayId(mapped)
111
  } catch { /* ignore */ }
112
  fetchGateways()
113
  }
 
190
  <GatewayCard
191
  key={gw.id}
192
  gateway={gw}
193
+ health={healthByGatewayId.get(gw.id)}
194
  isProbing={probing === gw.id}
195
  isCurrentlyConnected={connection.url?.includes(`:${gw.port}`) ?? false}
196
  onSetPrimary={() => setPrimary(gw)}
 
269
  )
270
  }
271
 
272
+ function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: {
273
  gateway: Gateway
274
+ health?: GatewayHealthProbe
275
  isProbing: boolean
276
  isCurrentlyConnected: boolean
277
  onSetPrimary: () => void
 
289
  const lastSeen = gateway.last_seen
290
  ? new Date(gateway.last_seen * 1000).toLocaleString()
291
  : 'Never probed'
292
+ const compatibilityWarning = health?.compatibility_warning
293
 
294
  return (
295
  <div className={`bg-card border rounded-lg p-4 transition-smooth ${
 
317
  {gateway.latency != null && <span>Latency: {gateway.latency}ms</span>}
318
  <span>Last: {lastSeen}</span>
319
  </div>
320
+ {health?.gateway_version && (
321
+ <div className="mt-1 text-2xs text-muted-foreground">
322
+ Gateway version: <span className="font-mono text-foreground/80">{health.gateway_version}</span>
323
+ </div>
324
+ )}
325
+ {compatibilityWarning && (
326
+ <div className="mt-1.5 text-2xs rounded border border-amber-500/30 bg-amber-500/10 text-amber-300 px-2 py-1">
327
+ {compatibilityWarning}
328
+ </div>
329
+ )}
330
  </div>
331
  <div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
332
  <button
src/lib/websocket.ts CHANGED
@@ -13,6 +13,7 @@ import { APP_VERSION } from '@/lib/version'
13
 
14
  // Gateway protocol version (v3 required by OpenClaw 2026.x)
15
  const PROTOCOL_VERSION = 3
 
16
 
17
  // Heartbeat configuration
18
  const PING_INTERVAL_MS = 30_000
@@ -179,7 +180,7 @@ export function useWebSocket() {
179
 
180
  const cachedToken = getCachedDeviceToken()
181
 
182
- const clientId = 'gateway-client'
183
  const clientMode = 'ui'
184
  const role = 'operator'
185
  const scopes = ['operator.admin']
 
13
 
14
  // Gateway protocol version (v3 required by OpenClaw 2026.x)
15
  const PROTOCOL_VERSION = 3
16
+ const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui'
17
 
18
  // Heartbeat configuration
19
  const PING_INTERVAL_MS = 30_000
 
180
 
181
  const cachedToken = getCachedDeviceToken()
182
 
183
+ const clientId = DEFAULT_GATEWAY_CLIENT_ID
184
  const clientMode = 'ui'
185
  const role = 'operator'
186
  const scopes = ['operator.admin']