Spaces:
Sleeping
Sleeping
Nyk commited on
Commit ·
e7f2128
1
Parent(s): 2aff45a
fix: add OpenClaw 3.2 compatibility for spawn and gateway health
Browse files- .env.example +5 -0
- README.md +2 -0
- src/app/api/gateways/health/route.ts +30 -0
- src/app/api/spawn/route.ts +43 -7
- src/components/panels/multi-gateway-panel.tsx +33 -2
- src/lib/websocket.ts +2 -1
.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 |
-
|
| 41 |
-
|
| 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 =
|
| 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']
|