fix(openclaw): fix gateway status detection and improve error reporting (#13433)

### What this PR does

Before this PR:

- `getStatus` only probed health when status was `stopped` or `error`.
If the gateway crashed while status was `running`, the UI would never
detect the crash and keep showing "运行中" (running).
- `checkHealth` probed the gateway but never updated `gatewayStatus`, so
a failed health check had no lasting effect.
- `startAndWaitForGateway` spawned the process with `stdio: 'ignore'`,
discarding stderr. On startup failure, the user only saw "gateway exited
with code 1" with no actionable detail.
- `parseUpdateStatus` matched any update channel (npm, pkg, binary),
causing false positives for binary-installed users when only an npm/pkg
update was available.

After this PR:

- `getStatus` probes health in all non-`starting` states, transitioning
`running → stopped` when the gateway is unreachable.
- `checkHealth` syncs `gatewayStatus` to `stopped` when the probe
returns unhealthy.
- `startAndWaitForGateway` pipes stderr and extracts the first 3 lines
of error output for meaningful error messages.
- `parseUpdateStatus` only matches binary-channel updates, ignoring
npm/pkg channels.
- 29 new state-machine tests cover all gateway status transitions and
lifecycle scenarios.

Fixes #

### Why we need it and why it was done in this way

The gateway status was a one-way latch: once set to `running`, it could
only be changed by an explicit `stopGateway` call. External crashes
(process killed, config errors, port conflicts) left the UI in an
incorrect state.

The following tradeoffs were made:

- `getStatus` now always calls `probeGatewayHealth` (except during
`starting`). This adds a small overhead per status poll, but is
necessary for correctness since the gateway process lifecycle is
independent (detached).
- stderr capture uses `['ignore', 'ignore', 'pipe']` instead of full
`'pipe'` to minimize resource usage — we only need stderr for error
diagnostics.

The following alternatives were considered:

- Keeping a persistent health-check interval timer — rejected as
over-engineered for the current polling-based UI.
- Parsing the full `openclaw update status` table structure — rejected
in favor of simple regex matching on the `binary` channel keyword.

### Breaking changes

None.

### Special notes for your reviewer

- The `parseUpdateStatus` change means users who installed OpenClaw via
npm will no longer see update notifications in Cherry Studio. This is
intentional — Cherry Studio only manages binary installations.
- The test file grew significantly (from 90 lines to 595 lines) because
this is the first time the `OpenClawService` class has state-machine
test coverage.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
fix(openclaw): gateway status now correctly detects crashed/externally-stopped gateways; startup errors show detailed messages instead of generic exit codes; update checker only reports binary-channel updates
```

---------

Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
SuYao
2026-03-14 21:25:53 +08:00
committed by GitHub
parent f3244c3e9e
commit 774cb7a73c
8 changed files with 708 additions and 229 deletions

View File

@@ -417,7 +417,6 @@ export enum IpcChannel {
OpenClaw_InstallProgress = 'openclaw:install-progress',
OpenClaw_StartGateway = 'openclaw:start-gateway',
OpenClaw_StopGateway = 'openclaw:stop-gateway',
OpenClaw_RestartGateway = 'openclaw:restart-gateway',
OpenClaw_GetStatus = 'openclaw:get-status',
OpenClaw_CheckHealth = 'openclaw:check-health',
OpenClaw_GetDashboardUrl = 'openclaw:get-dashboard-url',

View File

@@ -1163,7 +1163,6 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
ipcMain.handle(IpcChannel.OpenClaw_Uninstall, openClawService.uninstall)
ipcMain.handle(IpcChannel.OpenClaw_StartGateway, openClawService.startGateway)
ipcMain.handle(IpcChannel.OpenClaw_StopGateway, openClawService.stopGateway)
ipcMain.handle(IpcChannel.OpenClaw_RestartGateway, openClawService.restartGateway)
ipcMain.handle(IpcChannel.OpenClaw_GetStatus, openClawService.getStatus)
ipcMain.handle(IpcChannel.OpenClaw_CheckHealth, openClawService.checkHealth)
ipcMain.handle(IpcChannel.OpenClaw_GetDashboardUrl, openClawService.getDashboardUrl)

View File

@@ -1,3 +1,4 @@
import { execSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import { Socket } from 'node:net'
@@ -21,10 +22,10 @@ import { windowService } from './WindowService'
const logger = loggerService.withContext('OpenClawService')
const OPENCLAW_CONFIG_DIR = path.join(os.homedir(), '.openclaw')
// Original user config (read-only, used as template for first-time setup)
const OPENCLAW_ORIGINAL_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json')
// Cherry Studio's isolated config (read/write) — OpenClaw reads the OPENCLAW_CONFIG_PATH env var to locate this
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.cherry.json')
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json')
const OPENCLAW_CONFIG_BAK_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json.bak')
const OPENCLAW_LEGACY_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.cherry.json')
const SYMLINK_PATH = '/usr/local/bin/openclaw'
const DEFAULT_GATEWAY_PORT = 18790
export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error'
@@ -125,7 +126,6 @@ class OpenClawService {
this.uninstall = this.uninstall.bind(this)
this.startGateway = this.startGateway.bind(this)
this.stopGateway = this.stopGateway.bind(this)
this.restartGateway = this.restartGateway.bind(this)
this.getStatus = this.getStatus.bind(this)
this.checkHealth = this.checkHealth.bind(this)
this.getDashboardUrl = this.getDashboardUrl.bind(this)
@@ -172,6 +172,75 @@ class OpenClawService {
win?.webContents.send(IpcChannel.OpenClaw_InstallProgress, { message, type })
}
/**
* Create a symlink in /usr/local/bin (macOS/Linux) or add bin dir to user PATH (Windows).
* Removes any existing symlink first to ensure a clean state.
*/
private async linkBinary(): Promise<void> {
const binaryPath = await getBinaryPath('openclaw')
if (isWin) {
const binDir = await getBinaryPath()
try {
const regQuery = execSync('reg query "HKCU\\Environment" /v Path', { encoding: 'utf-8' })
const currentPath = regQuery.match(/Path\s+REG_\w+\s+(.*)/)?.[1]?.trim() || ''
if (!currentPath.split(';').some((p) => p.toLowerCase() === binDir.toLowerCase())) {
const newPath = currentPath ? `${currentPath};${binDir}` : binDir
execSync(`reg add "HKCU\\Environment" /v Path /t REG_EXPAND_SZ /d "${newPath}" /f`)
// Broadcast WM_SETTINGCHANGE so new shells pick up the change
execSync('setx OPENCLAW_PATH_REFRESH ""')
logger.info(`Added ${binDir} to user PATH`)
}
} catch {
// User PATH key may not exist yet
execSync(`reg add "HKCU\\Environment" /v Path /t REG_EXPAND_SZ /d "${binDir}" /f`)
logger.info(`Created user PATH with ${binDir}`)
}
} else {
try {
// Remove existing symlink or file at target path
if (fs.existsSync(SYMLINK_PATH)) {
fs.unlinkSync(SYMLINK_PATH)
}
fs.symlinkSync(binaryPath, SYMLINK_PATH)
logger.info(`Created symlink: ${SYMLINK_PATH} -> ${binaryPath}`)
} catch (err) {
logger.warn(`Failed to create symlink at ${SYMLINK_PATH} (may need elevated permissions):`, err as Error)
}
}
}
/**
* Remove the symlink from /usr/local/bin (macOS/Linux) or remove bin dir from user PATH (Windows).
*/
private async unlinkBinary(): Promise<void> {
if (isWin) {
const binDir = await getBinaryPath()
try {
const regQuery = execSync('reg query "HKCU\\Environment" /v Path', { encoding: 'utf-8' })
const currentPath = regQuery.match(/Path\s+REG_\w+\s+(.*)/)?.[1]?.trim() || ''
const parts = currentPath.split(';').filter((p) => p.toLowerCase() !== binDir.toLowerCase())
const newPath = parts.join(';')
if (newPath) {
execSync(`reg add "HKCU\\Environment" /v Path /t REG_EXPAND_SZ /d "${newPath}" /f`)
} else {
execSync('reg delete "HKCU\\Environment" /v Path /f')
}
logger.info(`Removed ${binDir} from user PATH`)
} catch {
logger.debug('No user PATH to clean up')
}
} else {
try {
if (fs.existsSync(SYMLINK_PATH)) {
fs.unlinkSync(SYMLINK_PATH)
logger.info(`Removed symlink: ${SYMLINK_PATH}`)
}
} catch (err) {
logger.warn(`Failed to remove symlink at ${SYMLINK_PATH}:`, err as Error)
}
}
}
/**
* Install OpenClaw by downloading the binary from releases.
* Uses gitcode.com mirror for China users, GitHub releases for others.
@@ -190,6 +259,8 @@ class OpenClawService {
this.sendInstallProgress('Downloading and installing OpenClaw...')
await runInstallScript('install-openclaw.js', extraEnv)
await this.linkBinary()
this.sendInstallProgress('OpenClaw installed successfully!')
logger.info('OpenClaw binary installed via install script')
@@ -218,6 +289,8 @@ class OpenClawService {
this.sendInstallProgress('Removing OpenClaw binary...')
await this.unlinkBinary()
if (fs.existsSync(binaryPath)) {
fs.unlinkSync(binaryPath)
logger.info(`Removed OpenClaw binary: ${binaryPath}`)
@@ -262,19 +335,25 @@ class OpenClawService {
const isPortOpen = await this.checkPortOpen(this.gatewayPort)
if (isPortOpen) {
// Check if this is our gateway already running on this port
const shellEnv = await refreshShellEnv()
const openclawPath = await this.findOpenClawBinary()
if (openclawPath) {
const alreadyRunning = await this.checkGatewayStatus(openclawPath, shellEnv)
if (alreadyRunning) {
this.gatewayStatus = 'running'
logger.info(`Reusing existing gateway on port ${this.gatewayPort}`)
return { success: true }
const { status } = await this.checkGatewayHealth()
if (status === 'healthy') {
// Stop the stale gateway (e.g. respawned orphan from a previous session)
logger.info('Detected stale gateway on port, stopping before restart...')
await this.stopGateway()
// Verify port is now free
const stillOpen = await this.checkPortOpen(this.gatewayPort)
if (stillOpen) {
return {
success: false,
message: `Port ${this.gatewayPort} is still in use after stopping the old gateway.`
}
}
} else {
return {
success: false,
message: `Port ${this.gatewayPort} is already in use by another application. Please choose a different port.`
}
}
return {
success: false,
message: `Port ${this.gatewayPort} is already in use by another application. Please choose a different port.`
}
}
@@ -304,31 +383,41 @@ class OpenClawService {
}
/**
* Start gateway via `openclaw gateway --force` and wait for it to become ready.
* Start gateway via `openclaw gateway run --force` and wait for it to become ready.
* Spawns the gateway as a detached process so its lifecycle is independent.
* Uses `openclaw gateway stop` to stop it later.
* Uses process termination to stop it later.
*/
private async startAndWaitForGateway(openclawPath: string, shellEnv: Record<string, string>): Promise<void> {
const args = ['gateway', '--force']
const args = ['gateway', 'run', '--force']
logger.info(`Starting gateway: ${openclawPath} ${args.join(' ')}`)
// Spawn detached — the gateway runs independently, we poll for readiness
const proc = crossPlatformSpawn(openclawPath, args, {
env: { ...shellEnv, OPENCLAW_CONFIG_PATH },
detached: true,
stdio: 'ignore'
// Spawn the gateway process. We poll for readiness via health check.
// On Windows, avoid detached: true as it creates a visible console window.
// Instead, use windowsHide: true without detached - proc.unref() ensures
// the parent can exit independently.
const proc = spawn(openclawPath, args, {
env: shellEnv,
detached: !isWin, // Only detach on non-Windows to avoid console flash
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
proc.unref()
// Collect early exit errors (e.g. binary crash on startup)
let earlyExitError = ''
let stderrOutput = ''
proc.stderr?.on('data', (data) => {
stderrOutput += data.toString()
})
proc.on('error', (err) => {
earlyExitError = err.message
})
proc.on('exit', (code) => {
if (code !== 0) {
earlyExitError = `gateway exited with code ${code}`
// Extract the most useful line from stderr for the error message
const detail = stderrOutput.trim().split('\n').filter(Boolean).slice(0, 3).join('\n')
earlyExitError = detail || `gateway exited with code ${code}`
}
})
@@ -349,7 +438,7 @@ class OpenClawService {
}
logger.debug(`Polling gateway health (attempt ${pollCount})...`)
const { status, error: healthError } = await this.probeGatewayHealthWithError(openclawPath, shellEnv)
const { status, error: healthError } = await this.checkGatewayHealthWithError()
if (status === 'healthy') {
logger.info(`Gateway is healthy (verified after ${pollCount} polls)`)
return
@@ -362,23 +451,17 @@ class OpenClawService {
}
/**
* Stop the OpenClaw Gateway
* Stop the OpenClaw Gateway.
* Kills all openclaw processes to ensure clean shutdown.
*/
public async stopGateway(): Promise<OperationResult> {
try {
const openclawPath = await this.findOpenClawBinary()
if (!openclawPath) {
this.gatewayStatus = 'error'
return { success: false, message: 'OpenClaw binary not found' }
}
this.killAllOpenClawProcesses()
const shellEnv = await getShellEnv()
await this.runGatewayStop(openclawPath, shellEnv)
const stillRunning = await this.waitForGatewayStop(openclawPath, shellEnv)
const stillRunning = await this.waitForGatewayStop()
if (stillRunning) {
this.gatewayStatus = 'error'
return { success: false, message: 'Failed to stop gateway. Try running: openclaw gateway stop' }
return { success: false, message: 'Failed to stop gateway' }
}
this.gatewayStatus = 'stopped'
@@ -392,18 +475,47 @@ class OpenClawService {
}
}
/**
* Kill all openclaw processes by finding processes on the gateway port.
* This works reliably on Windows where process name may show as bun.exe.
*/
private killAllOpenClawProcesses(): void {
const currentPid = process.pid
try {
if (isWin) {
const output = execSync(`netstat -ano | findstr ":${this.gatewayPort}"`, { encoding: 'utf-8' })
const pids = new Set<string>()
for (const line of output.split('\n')) {
const match = line.trim().match(/LISTENING\s+(\d+)/)
if (match && Number(match[1]) !== currentPid) {
pids.add(match[1])
}
}
for (const pid of pids) {
try {
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' })
logger.info(`Killed process ${pid} on port ${this.gatewayPort}`)
} catch {
// ignore
}
}
} else {
execSync('pkill -9 openclaw', { stdio: 'ignore' })
logger.info('Killed all openclaw processes')
}
} catch {
logger.debug('No openclaw processes to kill')
}
}
/**
* Wait for gateway to actually stop, with retries.
* Returns true if gateway is still running after all retries.
*/
private async waitForGatewayStop(
openclawPath: string,
env: Record<string, string>,
maxRetries = 3,
intervalMs = 1000
): Promise<boolean> {
private async waitForGatewayStop(maxRetries = 3, intervalMs = 1000): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
const stillRunning = await this.checkGatewayStatus(openclawPath, env)
const { status } = await this.checkGatewayHealth()
const stillRunning = status === 'healthy'
if (!stillRunning) {
return false
}
@@ -415,10 +527,6 @@ class OpenClawService {
return true
}
private async runGatewayStop(openclawPath: string, env: Record<string, string>): Promise<void> {
await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'stop'], env)
}
private async execOpenClawCommandWithResult(
openclawPath: string,
args: string[],
@@ -426,9 +534,7 @@ class OpenClawService {
timeoutMs = 20000
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const proc = crossPlatformSpawn(openclawPath, args, {
env: { ...env, OPENCLAW_CONFIG_PATH }
})
const proc = crossPlatformSpawn(openclawPath, args, { env })
let stdout = ''
let stderr = ''
@@ -460,35 +566,23 @@ class OpenClawService {
})
}
/**
* Restart the OpenClaw Gateway
*/
public async restartGateway(): Promise<OperationResult> {
const openclawPath = await this.findOpenClawBinary()
if (!openclawPath) {
this.gatewayStatus = 'error'
return { success: false, message: 'OpenClaw binary not found' }
}
const shellEnv = await getShellEnv()
const { code, stderr } = await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'restart'], shellEnv)
if (code !== 0) {
this.gatewayStatus = 'error'
return { success: false, message: stderr.trim() || `Restart failed with code ${code}` }
}
return { success: true }
}
/**
* Get Gateway status. Probes the port when idle to detect externally-started gateways.
*/
public async getStatus(): Promise<{ status: GatewayStatus; port: number }> {
if (this.gatewayStatus === 'stopped' || this.gatewayStatus === 'error') {
const { status } = await this.probeGatewayHealth()
if (status === 'healthy') {
logger.info(`Detected externally running gateway on port ${this.gatewayPort}`)
this.gatewayStatus = 'running'
}
if (this.gatewayStatus === 'starting') {
return { status: this.gatewayStatus, port: this.gatewayPort }
}
const { status } = await this.checkGatewayHealth()
if (status === 'healthy' && this.gatewayStatus !== 'running') {
logger.info(`Detected externally running gateway on port ${this.gatewayPort}`)
this.gatewayStatus = 'running'
} else if (status === 'unhealthy' && this.gatewayStatus === 'running') {
logger.warn(`Gateway on port ${this.gatewayPort} is no longer reachable, marking as stopped`)
this.gatewayStatus = 'stopped'
}
return {
status: this.gatewayStatus,
port: this.gatewayPort
@@ -503,24 +597,31 @@ class OpenClawService {
if (this.gatewayStatus !== 'running') {
return { status: 'unhealthy', gatewayPort: this.gatewayPort }
}
return this.probeGatewayHealth()
const healthInfo = await this.checkGatewayHealth()
if (healthInfo.status === 'unhealthy') {
logger.warn(`Gateway health check failed, marking as stopped`)
this.gatewayStatus = 'stopped'
}
return healthInfo
}
/**
* Probe gateway health by running `openclaw gateway health`.
* Probe gateway health via HTTP request to the health endpoint.
* This is faster than spawning the openclaw binary.
* Expected response: {"ok":true,"status":"live"}
* Does NOT check gatewayStatus — callers that need to detect
* externally-started gateways should call this directly.
*/
private async probeGatewayHealth(): Promise<HealthInfo> {
private async checkGatewayHealth(): Promise<HealthInfo> {
try {
const openclawPath = await this.findOpenClawBinary()
if (!openclawPath) {
return { status: 'unhealthy', gatewayPort: this.gatewayPort }
}
const shellEnv = await getShellEnv()
const { code } = await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'health'], shellEnv)
if (code === 0) {
return { status: 'healthy', gatewayPort: this.gatewayPort }
const response = await fetch(`http://127.0.0.1:${this.gatewayPort}/health`, {
signal: AbortSignal.timeout(3000)
})
if (response.ok) {
const data = (await response.json()) as { ok?: boolean; status?: string }
if (data.ok && data.status === 'live') {
return { status: 'healthy', gatewayPort: this.gatewayPort }
}
}
} catch (error) {
logger.debug('Health probe failed:', error as Error)
@@ -538,20 +639,23 @@ class OpenClawService {
socket.on('connect', () => {
socket.destroy()
logger.debug(`Port ${port} is open (connected)`)
resolve(true)
})
socket.on('timeout', () => {
socket.destroy()
logger.debug(`Port ${port} check timed out`)
resolve(false)
})
socket.on('error', () => {
socket.on('error', (err) => {
socket.destroy()
logger.debug(`Port ${port} is not open: ${err.message}`)
resolve(false)
})
socket.connect(port, 'localhost')
socket.connect(port, '127.0.0.1')
})
}
@@ -611,22 +715,24 @@ class OpenClawService {
fs.mkdirSync(OPENCLAW_CONFIG_DIR, { recursive: true })
}
// Read existing cherry config, or copy from original openclaw.json as base
// Migrate legacy openclaw.cherry.json → openclaw.json
if (fs.existsSync(OPENCLAW_LEGACY_CONFIG_PATH)) {
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
fs.renameSync(OPENCLAW_CONFIG_PATH, OPENCLAW_CONFIG_BAK_PATH)
logger.info('Migrated openclaw.json → openclaw.json.bak')
}
fs.renameSync(OPENCLAW_LEGACY_CONFIG_PATH, OPENCLAW_CONFIG_PATH)
logger.info('Migrated openclaw.cherry.json → openclaw.json')
}
// Read existing config
let config: OpenClawConfig = {}
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
try {
const content = fs.readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8')
config = JSON.parse(content)
} catch {
logger.warn('Failed to parse existing Cherry OpenClaw config, creating new one')
}
} else if (fs.existsSync(OPENCLAW_ORIGINAL_CONFIG_PATH)) {
try {
const content = fs.readFileSync(OPENCLAW_ORIGINAL_CONFIG_PATH, 'utf-8')
config = JSON.parse(content)
logger.info('Using original openclaw.json as base template for openclaw.cherry.json')
} catch {
logger.warn('Failed to parse original openclaw.json, creating new config')
logger.warn('Failed to parse existing OpenClaw config, creating new one')
}
}
@@ -814,65 +920,23 @@ class OpenClawService {
}
/**
* Check gateway status using `openclaw gateway status` command
* Returns true if gateway is running
* Like checkGatewayHealth but also returns error message when unhealthy.
* Uses HTTP request for faster health checks.
* Expected response: {"ok":true,"status":"live"}
*/
private async checkGatewayStatus(openclawPath: string, env: Record<string, string>): Promise<boolean> {
return new Promise((resolve) => {
const statusProcess = crossPlatformSpawn(openclawPath, ['gateway', 'status'], {
env: { ...env, OPENCLAW_CONFIG_PATH }
})
let stdout = ''
let resolved = false
const doResolve = (value: boolean) => {
if (resolved) return
resolved = true
resolve(value)
}
const timeoutId = setTimeout(() => {
// On timeout, check stdout accumulated so far before giving up
const lowerStdout = stdout.toLowerCase()
const isRunning = lowerStdout.includes('listening')
logger.debug(`Gateway status check timed out after 10s, stdout indicates running: ${isRunning}`)
statusProcess.kill('SIGKILL')
doResolve(isRunning)
}, 10_000)
statusProcess.stdout?.on('data', (data) => {
stdout += data.toString()
})
statusProcess.on('close', (code) => {
clearTimeout(timeoutId)
const lowerStdout = stdout.toLowerCase()
const isRunning = (code === 0 || code === null) && lowerStdout.includes('listening')
logger.debug('Gateway status check result:', { code, stdout: stdout.trim(), isRunning })
doResolve(isRunning)
})
statusProcess.on('error', () => {
clearTimeout(timeoutId)
doResolve(false)
})
})
}
/**
* Like probeGatewayHealth but also returns stderr when unhealthy.
*/
private async probeGatewayHealthWithError(
openclawPath: string,
env: Record<string, string>
): Promise<{ status: 'healthy' | 'unhealthy'; error?: string }> {
private async checkGatewayHealthWithError(): Promise<{ status: 'healthy' | 'unhealthy'; error?: string }> {
try {
const { code, stderr } = await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'health'], env)
if (code === 0) {
return { status: 'healthy' }
const response = await fetch(`http://127.0.0.1:${this.gatewayPort}/health`, {
signal: AbortSignal.timeout(3000)
})
if (response.ok) {
const data = (await response.json()) as { ok?: boolean; status?: string }
if (data.ok && data.status === 'live') {
return { status: 'healthy' }
}
return { status: 'unhealthy', error: `Gateway not live: ${JSON.stringify(data)}` }
}
return { status: 'unhealthy', error: stderr.trim() || undefined }
return { status: 'unhealthy', error: `HTTP ${response.status}: ${response.statusText}` }
} catch (error) {
return { status: 'unhealthy', error: error instanceof Error ? error.message : String(error) }
}

View File

@@ -1,7 +1,410 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseCurrentVersion, parseUpdateStatus } from '../utils/openClawParsers'
// --- Mocks for OpenClawService dependencies ---
vi.mock('@main/services/WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => ({
webContents: { send: vi.fn() }
}))
}
}))
vi.mock('@main/utils/process', () => ({
crossPlatformSpawn: vi.fn(),
findExecutableInEnv: vi.fn(),
getBinaryPath: vi.fn(() => Promise.resolve('/mock/bin/openclaw')),
runInstallScript: vi.fn()
}))
vi.mock('@main/utils/shell-env', () => ({
default: vi.fn(() => Promise.resolve({ PATH: '/usr/bin' })),
refreshShellEnv: vi.fn(() => Promise.resolve({ PATH: '/usr/bin' }))
}))
vi.mock('@main/utils/ipService', () => ({
isUserInChina: vi.fn(() => Promise.resolve(false))
}))
vi.mock('@main/constant', () => ({
isWin: false
}))
vi.mock('@shared/IpcChannel', () => ({
IpcChannel: { OpenClaw_InstallProgress: 'openclaw:install-progress' }
}))
vi.mock('@shared/utils', () => ({
hasAPIVersion: vi.fn(() => false),
withoutTrailingSlash: vi.fn((url: string) => url.replace(/\/+$/, ''))
}))
// openClawParsers: not mocked — tested directly below
vi.mock('../VertexAIService', () => ({
default: { getInstance: vi.fn() }
}))
// --- Import service after mocks are set up ---
async function createService() {
const mod = await import('../OpenClawService')
return mod.openClawService
}
describe('OpenClawService gateway status state machine', () => {
let service: Awaited<ReturnType<typeof createService>>
let checkHealthSpy: ReturnType<typeof vi.spyOn>
let findBinarySpy: ReturnType<typeof vi.spyOn>
let checkPortOpenSpy: ReturnType<typeof vi.spyOn>
let startAndWaitSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
vi.clearAllMocks()
service = await createService()
// Reset internal state to 'stopped' via reflection
// @ts-expect-error -- accessing private field for testing
service.gatewayStatus = 'stopped'
// @ts-expect-error -- accessing private field for testing
service.gatewayPort = 18790
// @ts-expect-error -- accessing private field for testing
service.gatewayAuthToken = ''
// Spy on private methods via prototype
checkHealthSpy = vi.spyOn(service as any, 'checkGatewayHealth')
findBinarySpy = vi.spyOn(service as any, 'findOpenClawBinary')
checkPortOpenSpy = vi.spyOn(service as any, 'checkPortOpen')
startAndWaitSpy = vi.spyOn(service as any, 'startAndWaitForGateway')
})
afterEach(() => {
vi.restoreAllMocks()
})
// ─── getStatus ───────────────────────────────────────────────
describe('getStatus', () => {
it('returns "starting" immediately without probing health', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'starting'
const result = await service.getStatus()
expect(result).toEqual({ status: 'starting', port: 18790 })
expect(checkHealthSpy).not.toHaveBeenCalled()
})
it('detects externally running gateway when stopped', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'stopped'
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
const result = await service.getStatus()
expect(result).toEqual({ status: 'running', port: 18790 })
})
it('detects externally running gateway when in error state', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'error'
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
const result = await service.getStatus()
expect(result).toEqual({ status: 'running', port: 18790 })
})
it('detects crashed gateway and transitions running → stopped', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
const result = await service.getStatus()
expect(result).toEqual({ status: 'stopped', port: 18790 })
})
it('stays running when health probe is healthy', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
const result = await service.getStatus()
expect(result).toEqual({ status: 'running', port: 18790 })
})
it('stays stopped when health probe is unhealthy', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'stopped'
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
const result = await service.getStatus()
expect(result).toEqual({ status: 'stopped', port: 18790 })
})
it('stays in error when health probe is unhealthy', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'error'
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
const result = await service.getStatus()
expect(result).toEqual({ status: 'error', port: 18790 })
})
})
// ─── checkHealth ─────────────────────────────────────────────
describe('checkHealth', () => {
it('returns unhealthy immediately when status is stopped', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'stopped'
const result = await service.checkHealth()
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
expect(checkHealthSpy).not.toHaveBeenCalled()
})
it('returns unhealthy immediately when status is error', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'error'
const result = await service.checkHealth()
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
expect(checkHealthSpy).not.toHaveBeenCalled()
})
it('returns unhealthy immediately when status is starting', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'starting'
const result = await service.checkHealth()
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
expect(checkHealthSpy).not.toHaveBeenCalled()
})
it('probes and returns healthy when gateway is running and reachable', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
const result = await service.checkHealth()
expect(result).toEqual({ status: 'healthy', gatewayPort: 18790 })
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('running')
})
it('transitions running → stopped when probe returns unhealthy', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
const result = await service.checkHealth()
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('stopped')
})
})
// ─── startGateway ────────────────────────────────────────────
describe('startGateway', () => {
const event = {} as Electron.IpcMainInvokeEvent
it('rejects concurrent startup calls', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'starting'
const result = await service.startGateway(event)
expect(result).toEqual({ success: false, message: 'Gateway is already starting' })
})
it('stops stale gateway and restarts when port is in use by our gateway', async () => {
// First call: port occupied; after stop: port free
checkPortOpenSpy.mockResolvedValueOnce(true).mockResolvedValue(false)
checkHealthSpy
.mockResolvedValueOnce({ status: 'healthy', gatewayPort: 18790 }) // startGateway detects our gateway
.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 }) // waitForGatewayStop confirms stopped
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
startAndWaitSpy.mockResolvedValue(undefined)
const result = await service.startGateway(event)
expect(result).toEqual({ success: true })
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('running')
})
it('fails when port is in use by another application', async () => {
checkPortOpenSpy.mockResolvedValue(true)
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
const result = await service.startGateway(event)
expect(result.success).toBe(false)
expect('message' in result && result.message).toContain('already in use')
})
it('fails when binary is not found', async () => {
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue(null)
const result = await service.startGateway(event)
expect(result).toEqual({
success: false,
message: 'OpenClaw binary not found. Please install OpenClaw first.'
})
})
it('transitions to running on successful start', async () => {
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
startAndWaitSpy.mockResolvedValue(undefined)
const result = await service.startGateway(event)
expect(result).toEqual({ success: true })
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('running')
})
it('transitions to error when start fails', async () => {
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
startAndWaitSpy.mockRejectedValue(new Error('Gateway timeout'))
const result = await service.startGateway(event)
expect(result).toEqual({ success: false, message: 'Gateway timeout' })
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('error')
})
it('sets status to starting during startup', async () => {
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
let statusDuringStart: string | undefined
startAndWaitSpy.mockImplementation(async () => {
// @ts-expect-error -- accessing private field
statusDuringStart = service.gatewayStatus
})
await service.startGateway(event)
expect(statusDuringStart).toBe('starting')
})
it('uses custom port when provided', async () => {
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
startAndWaitSpy.mockResolvedValue(undefined)
await service.startGateway(event, 9999)
// @ts-expect-error -- accessing private field
expect(service.gatewayPort).toBe(9999)
})
})
// ─── stopGateway ─────────────────────────────────────────────
describe('stopGateway', () => {
it('transitions to stopped on successful stop', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 }) // gateway stopped
const result = await service.stopGateway()
expect(result).toEqual({ success: true })
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('stopped')
})
it('transitions to error when gateway fails to stop', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 }) // still running
const result = await service.stopGateway()
expect(result.success).toBe(false)
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('error')
})
})
// ─── Full state transition scenarios ─────────────────────────
describe('full lifecycle transitions', () => {
const event = {} as Electron.IpcMainInvokeEvent
it('stopped → starting → running → (crash) → stopped', async () => {
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('stopped')
// Start
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
startAndWaitSpy.mockResolvedValue(undefined)
await service.startGateway(event)
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('running')
// Gateway crashes externally — getStatus detects it
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
const status = await service.getStatus()
expect(status.status).toBe('stopped')
})
it('stopped → starting → error → (external recovery) → running', async () => {
// Start fails
checkPortOpenSpy.mockResolvedValue(false)
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
startAndWaitSpy.mockRejectedValue(new Error('timeout'))
await service.startGateway(event)
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('error')
// External recovery — someone starts gateway manually
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
const status = await service.getStatus()
expect(status.status).toBe('running')
})
it('running → checkHealth unhealthy → stopped → getStatus healthy → running', async () => {
// @ts-expect-error -- accessing private field
service.gatewayStatus = 'running'
// checkHealth detects crash
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
await service.checkHealth()
// @ts-expect-error -- accessing private field
expect(service.gatewayStatus).toBe('stopped')
// getStatus detects recovery
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
const status = await service.getStatus()
expect(status.status).toBe('running')
})
})
})
// ─── Parser tests (preserved from original) ─────────────────────
describe('parseCurrentVersion', () => {
const cases = [
{ name: 'standard version output', input: 'OpenClaw 2026.3.9 (fe96034)', expected: '2026.3.9' },
@@ -36,22 +439,46 @@ describe('parseCurrentVersion', () => {
describe('parseUpdateStatus', () => {
const cases = [
{
name: 'npm update available',
input: 'Update available (npm 2026.3.11). Run: openclaw update',
expected: '2026.3.11'
name: 'binary update via summary line',
input: 'Update available (binary 2026.3.12). Run: openclaw update',
expected: '2026.3.12'
},
{
name: 'pkg update available',
input: 'Update available · pkg · npm update 2026.3.11',
expected: '2026.3.11'
name: 'binary update via table row',
input: 'available · binary · 2026.3.12',
expected: '2026.3.12'
},
{
name: 'update available with semver',
input: 'Update available (npm 1.2.3). Run: openclaw update',
name: 'binary update with semver',
input: 'Update available (binary 1.2.3). Run: openclaw update',
expected: '1.2.3'
},
{
name: 'full table output with update',
name: 'full table output with binary update',
input: [
'OpenClaw update status',
'┌──────────┬─────────────────────────────────┐',
'│ Install │ binary (~/.cherrystudio/bin) │',
'│ Channel │ stable (default) │',
'│ Update │ available · binary · 2026.3.12 │',
'└──────────┴─────────────────────────────────┘',
'',
'Update available (binary 2026.3.12). Run: openclaw update'
].join('\n'),
expected: '2026.3.12'
},
{
name: 'ignores npm update (summary)',
input: 'Update available (npm 2026.3.11). Run: openclaw update',
expected: null
},
{
name: 'ignores pkg update (table row)',
input: 'Update available · pkg · npm update 2026.3.11',
expected: null
},
{
name: 'ignores pkg update in full table output',
input: [
'OpenClaw update status',
'┌──────────┬─────────────────────────────────┐',
@@ -62,7 +489,7 @@ describe('parseUpdateStatus', () => {
'',
'Update available (npm 2026.3.11). Run: openclaw update'
].join('\n'),
expected: '2026.3.11'
expected: null
},
{ name: 'no update available', input: 'Already up to date', expected: null },
{ name: 'empty string', input: '', expected: null },
@@ -77,13 +504,16 @@ describe('parseUpdateStatus', () => {
const results = Object.fromEntries(cases.map((c) => [c.name, parseUpdateStatus(c.input)]))
expect(results).toMatchInlineSnapshot(`
{
"binary update via summary line": "2026.3.12",
"binary update via table row": "2026.3.12",
"binary update with semver": "1.2.3",
"empty string": null,
"full table output with update": "2026.3.11",
"full table output with binary update": "2026.3.12",
"ignores npm update (summary)": null,
"ignores pkg update (table row)": null,
"ignores pkg update in full table output": null,
"no update available": null,
"npm update available": "2026.3.11",
"pkg update available": "2026.3.11",
"unrelated output": null,
"update available with semver": "1.2.3",
}
`)
})

View File

@@ -9,10 +9,25 @@ export function parseCurrentVersion(versionOutput: string): string | null {
/**
* Parse the update status from `openclaw update status` output.
* Returns the latest version string if an update is available, otherwise null.
* Example input: "Update available (npm 2026.3.11). Run: openclaw update"
* Returns the latest version string if a **binary** update is available, otherwise null.
*
* Cherry Studio installs OpenClaw as a standalone binary, so we only care about
* binary-channel updates. npm/pkg-channel updates are ignored because they
* require a different upgrade path (`npm update -g`).
*
* The table output contains a row like:
* │ Update │ available · binary · 2026.3.12 │
* And a summary line like:
* Update available (binary 2026.3.12). Run: openclaw update
*/
export function parseUpdateStatus(statusOutput: string): string | null {
const match = statusOutput.match(/Update available.*?(\d[\d.]+)/i)
return match?.[1] ?? null
// Match binary-channel update from table row: "available · binary · <version>"
const tableMatch = statusOutput.match(/available\s*·\s*binary\s*·?\s*([\d.]+)/i)
if (tableMatch) return tableMatch[1]
// Match binary-channel update from summary line: "Update available (binary <version>)"
const summaryMatch = statusOutput.match(/Update available\s*\(binary\s+([\d.]+)\)/i)
if (summaryMatch) return summaryMatch[1]
return null
}

View File

@@ -421,6 +421,9 @@ export function crossPlatformSpawn(
args: string[],
options: SpawnOptions & { env: Record<string, string> }
): ChildProcess {
// Always hide console window on Windows
const baseOptions: SpawnOptions = { ...options, windowsHide: true, stdio: options.stdio ?? 'pipe' }
if (isWin && !command.toLowerCase().endsWith('.exe')) {
// When shell: true, Node passes the command to cmd.exe as:
// cmd /d /s /c "command arg1 arg2"
@@ -428,9 +431,9 @@ export function crossPlatformSpawn(
// cmd.exe splits on the space. Wrapping in quotes fixes this:
// cmd /d /s /c ""C:\Program Files\nodejs\npm.cmd" arg1 arg2"
const quotedCommand = command.includes(' ') && !command.startsWith('"') ? `"${command}"` : command
return spawn(quotedCommand, args, { ...options, shell: true, stdio: options.stdio ?? 'pipe' })
return spawn(quotedCommand, args, { ...baseOptions, shell: true })
}
return spawn(command, args, { ...options, stdio: options.stdio ?? 'pipe' })
return spawn(command, args, baseOptions)
}
/**

View File

@@ -680,7 +680,6 @@ const api = {
startGateway: (port?: number): Promise<OperationResult> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_StartGateway, port),
stopGateway: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_StopGateway),
restartGateway: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_RestartGateway),
getStatus: (): Promise<{ status: OpenClawGatewayStatus; port: number }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_GetStatus),
checkHealth: (): Promise<OpenClawHealthInfo> => ipcRenderer.invoke(IpcChannel.OpenClaw_CheckHealth),

View File

@@ -15,7 +15,7 @@ import {
} from '@renderer/store/openclaw'
import { IpcChannel } from '@shared/IpcChannel'
import { Alert, Avatar, Button, Result, Space, Spin } from 'antd'
import { Download, ExternalLink, Play, RefreshCw, Square } from 'lucide-react'
import { Download, ExternalLink, Play, Square } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -82,8 +82,6 @@ const OpenClawPage: FC = () => {
const [isUninstalling, setIsUninstalling] = useState(false)
const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false)
const [isRestarting, setIsRestarting] = useState(false)
// Install progress logs
const [installLogs, setInstallLogs] = useState<Array<{ message: string; type: 'info' | 'warn' | 'error' }>>([])
const [showLogs, setShowLogs] = useState(false)
@@ -307,22 +305,6 @@ const OpenClawPage: FC = () => {
}
}
const handleRestartGateway = async () => {
setIsRestarting(true)
try {
const result = await window.api.openclaw.restartGateway()
if (result.success) {
dispatch(setGatewayStatus('running'))
} else {
setError(result.message)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setIsRestarting(false)
}
}
const handleOpenDashboard = async () => {
const dashboardUrl = await window.api.openclaw.getDashboardUrl()
openSmartMinapp({
@@ -413,8 +395,8 @@ const OpenClawPage: FC = () => {
<div className="m-auto min-h-fit w-130">
<TitleSection title={t('openclaw.title')} description={t('openclaw.description')} clickable docsUrl={docsUrl} />
{/* Install Path - hide when gateway is running or restarting */}
{installPath && gatewayStatus !== 'running' && !isRestarting && (
{/* Install Path - hide when gateway is running */}
{installPath && gatewayStatus !== 'running' && (
<div
className="mb-6 flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm"
style={{ background: 'var(--color-background-soft)', color: 'var(--color-text-3)' }}>
@@ -451,8 +433,8 @@ const OpenClawPage: FC = () => {
</div>
)}
{/* Gateway Status Card - show when running or restarting */}
{(gatewayStatus === 'running' || isRestarting) && (
{/* Gateway Status Card - show when running */}
{gatewayStatus === 'running' && (
<div
className="mb-6 flex items-center justify-between rounded-lg p-3"
style={{ background: 'var(--color-background-soft)' }}>
@@ -465,27 +447,16 @@ const OpenClawPage: FC = () => {
:{gatewayPort}
</span>
</div>
<div className="flex gap-1">
<Button
size="small"
type="text"
icon={<RefreshCw size={14} />}
onClick={handleRestartGateway}
loading={isRestarting}
disabled={isStopping || isRestarting}>
{t('openclaw.gateway.restart')}
</Button>
<Button
size="small"
type="text"
icon={<Square size={14} />}
onClick={handleStopGateway}
loading={isStopping}
disabled={isStopping || isRestarting}
danger>
{t('openclaw.gateway.stop')}
</Button>
</div>
<Button
size="small"
type="text"
icon={<Square size={14} />}
onClick={handleStopGateway}
loading={isStopping}
disabled={isStopping}
danger>
{t('openclaw.gateway.stop')}
</Button>
</div>
)}
@@ -495,7 +466,7 @@ const OpenClawPage: FC = () => {
<Alert
message={
<div className="flex items-start justify-between gap-2">
<span className="flex-1">{error}</span>
<span className="max-h-25 flex-1 overflow-y-auto whitespace-pre-wrap break-all">{error}</span>
<Button
type="link"
className="h-auto! w-3! shrink-0 p-0!"
@@ -520,8 +491,8 @@ const OpenClawPage: FC = () => {
</div>
)}
{/* Model Selector - only show when not running and not restarting */}
{gatewayStatus !== 'running' && !isRestarting && (
{/* Model Selector - only show when not running */}
{gatewayStatus !== 'running' && (
<div className="mb-6">
<div className="mb-2 flex items-center gap-2 font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
{t('openclaw.model_config.model')}
@@ -555,8 +526,7 @@ const OpenClawPage: FC = () => {
{showLogs && installLogs.length > 0 && renderLogContainer()}
{/* Action Button - hide when restarting */}
{gatewayStatus !== 'running' && !isRestarting && (
{gatewayStatus !== 'running' && (
<Button
type="primary"
icon={<Play size={16} />}
@@ -570,7 +540,7 @@ const OpenClawPage: FC = () => {
{t('openclaw.gateway.start')}
</Button>
)}
{(gatewayStatus === 'running' || isRestarting) && (
{gatewayStatus === 'running' && (
<Button type="primary" onClick={handleOpenDashboard} size="large" block>
{t('openclaw.quick_actions.open_dashboard')}
</Button>