From 4401e30fdca09812de0316c9459779d8ba6b82a0 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 19:08:50 +0530 Subject: [PATCH 01/15] feat(server): add /runtimes/* route surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniform HTTP surface backed by AgentRuntimeRegistry + runtime.executeAction: - GET /runtimes — list all registered runtimes (descriptor + status + capabilities) - GET /runtimes/:adapter/status — single status snapshot - GET /runtimes/:adapter/status/stream — SSE: snapshot on connect + every state transition - POST /runtimes/:adapter/actions/:action — capability-gated dispatch through executeAction - GET /runtimes/:adapter/logs — container-runtime logs (405 for host-process) Routes use zValidator for path/query/body so the typed RPC client picks up the schemas; mounted with the same requireTrustedAppOrigin middleware as /claw/* /terminal /acl-rules /monitoring. --- .../apps/server/src/api/routes/runtimes.ts | 167 +++++++++ .../apps/server/src/api/server.ts | 6 + .../server/tests/api/routes/runtimes.test.ts | 338 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 packages/browseros-agent/apps/server/src/api/routes/runtimes.ts create mode 100644 packages/browseros-agent/apps/server/tests/api/routes/runtimes.test.ts diff --git a/packages/browseros-agent/apps/server/src/api/routes/runtimes.ts b/packages/browseros-agent/apps/server/src/api/routes/runtimes.ts new file mode 100644 index 000000000..e79714d39 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/routes/runtimes.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { stream } from 'hono/streaming' +import { z } from 'zod' +import { + type AgentRuntime, + ContainerAgentRuntime, + getAgentRuntimeRegistry, + type RuntimeAction, + type RuntimeCapability, +} from '../../lib/agents/runtime' +import { logger } from '../../lib/logger' + +const RUNTIME_ACTION_NAMES = [ + 'install', + 'start', + 'stop', + 'restart', + 'reset-soft', + 'reset-wipe-agent', + 'reset-hard', + 'reinstall-cli', + 'check-auth', +] as const satisfies ReadonlyArray + +const AdapterParamSchema = z.object({ + adapter: z.string().min(1), +}) + +const ActionParamSchema = z.object({ + adapter: z.string().min(1), + action: z.enum(RUNTIME_ACTION_NAMES), +}) + +const ActionBodySchema = z.object({ + agentId: z.string().min(1).optional(), +}) + +const LogsQuerySchema = z.object({ + tail: z.coerce.number().int().min(1).max(2_000).optional(), +}) + +function buildRuntimeView(runtime: AgentRuntime) { + return { + descriptor: runtime.descriptor, + status: runtime.getStatusSnapshot(), + capabilities: runtime.getCapabilities(), + } +} + +export function createRuntimeRoutes() { + return new Hono() + .get('/', (c) => { + const runtimes = getAgentRuntimeRegistry().list().map(buildRuntimeView) + return c.json({ runtimes }) + }) + .get('/:adapter/status', zValidator('param', AdapterParamSchema), (c) => { + const { adapter } = c.req.valid('param') + const runtime = getAgentRuntimeRegistry().get(adapter) + if (!runtime) return c.json({ error: 'runtime not registered' }, 404) + return c.json(buildRuntimeView(runtime)) + }) + .get( + '/:adapter/status/stream', + zValidator('param', AdapterParamSchema), + (c) => { + const { adapter } = c.req.valid('param') + const runtime = getAgentRuntimeRegistry().get(adapter) + if (!runtime) return c.json({ error: 'runtime not registered' }, 404) + c.header('Content-Type', 'text/event-stream') + c.header('Cache-Control', 'no-cache') + c.header('Connection', 'keep-alive') + return stream(c, async (s) => { + const encoder = new TextEncoder() + const writeSnapshot = (snap: unknown) => + s + .write( + encoder.encode( + `event: snapshot\ndata: ${JSON.stringify(snap)}\n\n`, + ), + ) + .catch(() => {}) + await writeSnapshot(runtime.getStatusSnapshot()) + const unsubscribe = runtime.subscribe(writeSnapshot) + const heartbeat = setInterval(() => { + s.write( + encoder.encode( + `event: heartbeat\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`, + ), + ).catch(() => {}) + }, 15_000) + try { + await new Promise((resolve) => s.onAbort(() => resolve())) + } finally { + unsubscribe() + clearInterval(heartbeat) + } + }) + }, + ) + .post( + '/:adapter/actions/:action', + zValidator('param', ActionParamSchema), + zValidator('json', ActionBodySchema), + async (c) => { + const { adapter, action } = c.req.valid('param') + const body = c.req.valid('json') + const runtime = getAgentRuntimeRegistry().get(adapter) + if (!runtime) return c.json({ error: 'runtime not registered' }, 404) + if (!runtime.getCapabilities().includes(action as RuntimeCapability)) + return c.json({ error: 'action not supported' }, 405) + try { + if (action === 'reset-wipe-agent') { + if (!body.agentId) + return c.json( + { error: 'agentId required for reset-wipe-agent' }, + 400, + ) + await runtime.executeAction({ + type: 'reset-wipe-agent', + agentId: body.agentId, + }) + } else { + await runtime.executeAction({ type: action }) + } + return c.json({ + status: 'ok' as const, + state: runtime.getStatusSnapshot().state, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.warn('Runtime action failed', { + adapter, + action, + error: message, + }) + return c.json({ error: message }, 500) + } + }, + ) + .get( + '/:adapter/logs', + zValidator('param', AdapterParamSchema), + zValidator('query', LogsQuerySchema), + async (c) => { + const { adapter } = c.req.valid('param') + const { tail } = c.req.valid('query') + const runtime = getAgentRuntimeRegistry().get(adapter) + if (!runtime) return c.json({ error: 'runtime not registered' }, 404) + if (!(runtime instanceof ContainerAgentRuntime)) + return c.json({ error: 'logs not supported' }, 405) + try { + const lines = await runtime.getLogs(tail ?? 50) + return c.json({ lines }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return c.json({ error: message }, 500) + } + }, + ) +} diff --git a/packages/browseros-agent/apps/server/src/api/server.ts b/packages/browseros-agent/apps/server/src/api/server.ts index dfb569e93..8e8fcd787 100644 --- a/packages/browseros-agent/apps/server/src/api/server.ts +++ b/packages/browseros-agent/apps/server/src/api/server.ts @@ -36,6 +36,7 @@ import { createOAuthRoutes } from './routes/oauth' import { createOpenClawRoutes } from './routes/openclaw' import { createProviderRoutes } from './routes/provider' import { createRefinePromptRoutes } from './routes/refine-prompt' +import { createRuntimeRoutes } from './routes/runtimes' import { createShutdownRoute } from './routes/shutdown' import { createSkillsRoutes } from './routes/skills' import { createSoulRoutes } from './routes/soul' @@ -109,6 +110,10 @@ export async function createHttpServer(config: HttpServerConfig) { .use('/*', requireTrustedAppOrigin()) .route('/', createOpenClawRoutes()) + const runtimeRoutes = new Hono() + .use('/*', requireTrustedAppOrigin()) + .route('/', createRuntimeRoutes()) + const terminalRoutes = new Hono() .use('/*', requireTrustedAppOrigin()) .route( @@ -237,6 +242,7 @@ export async function createHttpServer(config: HttpServerConfig) { ) .route('/agents', agentRoutes) .route('/claw', clawRoutes) + .route('/runtimes', runtimeRoutes) // Error handler app.onError((err, c) => { diff --git a/packages/browseros-agent/apps/server/tests/api/routes/runtimes.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/runtimes.test.ts new file mode 100644 index 000000000..d4118d4f1 --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/routes/runtimes.test.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { createRuntimeRoutes } from '../../../src/api/routes/runtimes' +import { + type AgentRuntime, + ContainerAgentRuntime, + getAgentRuntimeRegistry, + resetAgentRuntimeRegistry, +} from '../../../src/lib/agents/runtime' +import type { ManagedContainerDeps } from '../../../src/lib/container/managed' +import type { + ContainerInfo, + ContainerSpec, +} from '../../../src/lib/container/types' + +interface FakeRuntimeOpts { + adapterId: string + kind: 'container' | 'host-process' + capabilities?: ReadonlyArray + state?: + | 'not_installed' + | 'installing' + | 'installed' + | 'starting' + | 'running' + | 'stopped' + | 'errored' + | 'cli_missing' + | 'cli_present' + | 'cli_unhealthy' + | 'unsupported_platform' + isReady?: boolean + executeAction?: (action: { type: string; agentId?: string }) => Promise + getLogs?: () => Promise +} + +function makeFakeRuntime(opts: FakeRuntimeOpts): AgentRuntime { + const subscribers = new Set<(snap: unknown) => void>() + const snapshot = { + adapterId: opts.adapterId, + state: opts.state ?? 'running', + isReady: opts.isReady ?? true, + lastError: null, + lastErrorAt: null, + } + const runtime: AgentRuntime = { + descriptor: { + adapterId: opts.adapterId, + displayName: opts.adapterId, + kind: opts.kind, + platforms: ['darwin'], + }, + getStatusSnapshot: () => ({ ...snapshot }), + subscribe: (listener) => { + subscribers.add(listener) + return () => { + subscribers.delete(listener) + } + }, + getCapabilities: () => + opts.capabilities ?? + (opts.kind === 'container' + ? ['install', 'start', 'stop', 'restart', 'reset-soft', 'logs'] + : ['reinstall-cli', 'check-auth']), + executeAction: + opts.executeAction ?? + (async () => { + /* noop */ + }), + buildExecArgv: () => '', + getPerAgentHomeDir: () => '/tmp', + } + return runtime +} + +function makeContainerLikeRuntime( + opts: FakeRuntimeOpts & { + getLogs: () => Promise + }, +): ContainerAgentRuntime { + // Create a real ContainerAgentRuntime subclass instance so the + // route's `instanceof ContainerAgentRuntime` check passes. + const fakeCli = { + inspectContainer: async (): Promise => null, + removeContainer: async () => {}, + waitForContainerNameRelease: async () => {}, + createContainer: async () => {}, + startContainer: async () => {}, + waitForContainerRunning: async () => {}, + exec: async () => 0, + runCommand: async (args: string[], onLog?: (line: string) => void) => { + const lines = await opts.getLogs() + for (const line of lines) onLog?.(line) + return { exitCode: 0, stdout: '', stderr: '' } + }, + tailLogs: () => () => {}, + containerImageRef: async () => null, + } + const deps: ManagedContainerDeps = { + cli: fakeCli as unknown as ManagedContainerDeps['cli'], + loader: {} as ManagedContainerDeps['loader'], + vm: {} as ManagedContainerDeps['vm'], + limactlPath: '/x', + limaHome: '/x', + vmName: 'vm', + lockDir: '/tmp', + } + class FakeContainerRuntime extends ContainerAgentRuntime { + readonly descriptor = { + adapterId: opts.adapterId, + displayName: opts.adapterId, + kind: 'container' as const, + defaultImage: 'docker.io/x:latest', + containerName: `${opts.adapterId}-test`, + platforms: ['darwin' as NodeJS.Platform], + } + getPerAgentHomeDir() { + return '/tmp' + } + protected mountRoots() { + return [] + } + protected async buildContainerSpec(): Promise { + return { + name: this.descriptor.containerName, + image: this.descriptor.defaultImage, + } + } + protected async readinessProbe() { + return true + } + override getCapabilities() { + return (opts.capabilities ?? ['logs', 'start', 'stop']) as ReturnType< + ContainerAgentRuntime['getCapabilities'] + > + } + } + return new FakeContainerRuntime(deps) +} + +describe('createRuntimeRoutes', () => { + beforeEach(() => { + resetAgentRuntimeRegistry() + }) + + afterEach(() => { + resetAgentRuntimeRegistry() + }) + + function registry() { + return getAgentRuntimeRegistry() + } + + describe('GET /', () => { + it('returns descriptor + status + capabilities for every registered runtime', async () => { + registry().register( + makeFakeRuntime({ adapterId: 'claude', kind: 'host-process' }), + ) + registry().register( + makeFakeRuntime({ adapterId: 'hermes', kind: 'container' }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/') + expect(res.status).toBe(200) + const body = (await res.json()) as { + runtimes: Array<{ descriptor: { adapterId: string } }> + } + expect(body.runtimes.map((r) => r.descriptor.adapterId).sort()).toEqual([ + 'claude', + 'hermes', + ]) + }) + }) + + describe('GET /:adapter/status', () => { + it('returns 200 with the runtime view for a registered adapter', async () => { + registry().register( + makeFakeRuntime({ adapterId: 'claude', kind: 'host-process' }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/claude/status') + expect(res.status).toBe(200) + const body = (await res.json()) as { capabilities: string[] } + expect(body.capabilities).toContain('reinstall-cli') + }) + + it('returns 404 for an unknown adapter', async () => { + const route = createRuntimeRoutes() + const res = await route.request('/unknown/status') + expect(res.status).toBe(404) + }) + }) + + describe('POST /:adapter/actions/:action', () => { + it('dispatches executeAction and returns the new state', async () => { + const calls: Array<{ type: string }> = [] + registry().register( + makeFakeRuntime({ + adapterId: 'hermes', + kind: 'container', + capabilities: ['start', 'stop'], + executeAction: async (action) => { + calls.push(action) + }, + }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/hermes/actions/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + expect(calls).toEqual([{ type: 'start' }]) + }) + + it('returns 405 when the action is not in capabilities', async () => { + registry().register( + makeFakeRuntime({ + adapterId: 'claude', + kind: 'host-process', + capabilities: ['check-auth'], + }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/claude/actions/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(405) + }) + + it('rejects unknown actions with 400', async () => { + registry().register( + makeFakeRuntime({ adapterId: 'claude', kind: 'host-process' }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/claude/actions/explode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it('requires agentId for reset-wipe-agent', async () => { + registry().register( + makeFakeRuntime({ + adapterId: 'hermes', + kind: 'container', + capabilities: ['reset-wipe-agent'], + }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/hermes/actions/reset-wipe-agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it('passes agentId through to executeAction for reset-wipe-agent', async () => { + const calls: Array<{ type: string; agentId?: string }> = [] + registry().register( + makeFakeRuntime({ + adapterId: 'hermes', + kind: 'container', + capabilities: ['reset-wipe-agent'], + executeAction: async (action) => { + calls.push(action) + }, + }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/hermes/actions/reset-wipe-agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: 'agent-7' }), + }) + expect(res.status).toBe(200) + expect(calls).toEqual([{ type: 'reset-wipe-agent', agentId: 'agent-7' }]) + }) + + it('returns 500 when executeAction throws', async () => { + registry().register( + makeFakeRuntime({ + adapterId: 'hermes', + kind: 'container', + capabilities: ['start'], + executeAction: async () => { + throw new Error('container is on fire') + }, + }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/hermes/actions/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(500) + const body = (await res.json()) as { error: string } + expect(body.error).toMatch(/on fire/) + }) + }) + + describe('GET /:adapter/logs', () => { + it('returns log lines for container runtimes', async () => { + registry().register( + makeContainerLikeRuntime({ + adapterId: 'hermes', + kind: 'container', + getLogs: async () => ['line-a', 'line-b'], + }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/hermes/logs?tail=20') + expect(res.status).toBe(200) + const body = (await res.json()) as { lines: string[] } + expect(body.lines).toEqual(['line-a', 'line-b']) + }) + + it('returns 405 for host-process runtimes (no container logs)', async () => { + registry().register( + makeFakeRuntime({ adapterId: 'claude', kind: 'host-process' }), + ) + const route = createRuntimeRoutes() + const res = await route.request('/claude/logs') + expect(res.status).toBe(405) + }) + }) +}) From 983e4338458a38c242ded8fd41f2379fbe065f92 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 19:10:25 +0530 Subject: [PATCH 02/15] feat(ui): add useRuntime / useRuntimeAction / useRuntimeLogs hooks Generic React Query hooks backed by the typed RPC client (hc), keyed by adapter id. useRuntime polls /runtimes/:adapter/status every 5s by default; useRuntimeAction issues a capability-gated POST to /runtimes/:adapter/actions/:action and invalidates the status query on success; useRuntimeLogs is opt-in (disabled by default) for container runtimes. --- .../entrypoints/app/agents/useRuntime.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts new file mode 100644 index 000000000..df45f99ea --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useRpcClient } from '@/lib/rpc/RpcClientProvider' + +export type RuntimeAdapterId = 'claude' | 'codex' | 'hermes' | 'openclaw' + +export type RuntimeKind = 'container' | 'host-process' + +export type RuntimeState = + | 'unsupported_platform' + | 'errored' + | 'not_installed' + | 'installing' + | 'installed' + | 'starting' + | 'running' + | 'stopped' + | 'cli_missing' + | 'cli_present' + | 'cli_unhealthy' + +export type RuntimeAction = + | 'install' + | 'start' + | 'stop' + | 'restart' + | 'reset-soft' + | 'reset-wipe-agent' + | 'reset-hard' + | 'reinstall-cli' + | 'check-auth' + +export interface RuntimeStatusSnapshot { + adapterId: string + state: RuntimeState + isReady: boolean + lastError: string | null + lastErrorAt: number | null + probedAt?: number | null + details?: Record +} + +export interface RuntimeView { + descriptor: { + adapterId: string + displayName: string + kind: RuntimeKind + platforms: ReadonlyArray + } + status: RuntimeStatusSnapshot + capabilities: ReadonlyArray +} + +export const RUNTIME_QUERY_KEYS = { + list: 'runtimes-list', + status: (adapter: RuntimeAdapterId) => ['runtime-status', adapter] as const, + logs: (adapter: RuntimeAdapterId) => ['runtime-logs', adapter] as const, +} as const + +export function useRuntime( + adapter: RuntimeAdapterId, + opts: { pollMs?: number; enabled?: boolean } = {}, +) { + const rpcClient = useRpcClient() + return useQuery({ + queryKey: RUNTIME_QUERY_KEYS.status(adapter), + queryFn: async () => { + const res = await rpcClient.runtimes[':adapter'].status.$get({ + param: { adapter }, + }) + if (!res.ok) { + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `runtime ${adapter} fetch failed`) + } + return (await res.json()) as RuntimeView + }, + refetchInterval: opts.pollMs ?? 5_000, + enabled: opts.enabled ?? true, + retry: false, + }) +} + +export function useRuntimeAction(adapter: RuntimeAdapterId) { + const queryClient = useQueryClient() + const rpcClient = useRpcClient() + return useMutation< + { status: 'ok'; state: RuntimeState }, + Error, + { action: RuntimeAction; agentId?: string } + >({ + mutationFn: async ({ action, agentId }) => { + const res = await rpcClient.runtimes[':adapter'].actions[':action'].$post( + { + param: { adapter, action }, + json: agentId ? { agentId } : {}, + }, + ) + if (!res.ok) { + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `runtime ${adapter} ${action} failed`) + } + return (await res.json()) as { status: 'ok'; state: RuntimeState } + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: RUNTIME_QUERY_KEYS.status(adapter), + }) + }, + }) +} + +export function useRuntimeLogs( + adapter: RuntimeAdapterId, + opts: { tail?: number; enabled?: boolean } = {}, +) { + const rpcClient = useRpcClient() + return useQuery<{ lines: string[] }, Error>({ + queryKey: [...RUNTIME_QUERY_KEYS.logs(adapter), opts.tail ?? 50], + queryFn: async () => { + const res = await rpcClient.runtimes[':adapter'].logs.$get({ + param: { adapter }, + query: { tail: opts.tail ? String(opts.tail) : undefined }, + }) + if (!res.ok) { + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? `runtime ${adapter} logs failed`) + } + return (await res.json()) as { lines: string[] } + }, + enabled: opts.enabled ?? false, + }) +} From 8eb911d83f124c9deb9c7b14aec1dfe97588448a Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 19:12:47 +0530 Subject: [PATCH 03/15] feat(ui): RuntimeStatusBar + RuntimeControlPanel components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeStatusBar — compact one-line bar with adapter name + state pill + optional Restart action. Reads from useRuntime(adapter); the pill covers every container and host-process state. extraPill / extraActions slots let openclaw add its control-plane pill and Open Terminal button without baking gateway specifics into the runtime layer. RuntimeControlPanel — capability-gated state-appropriate primary CTA: not_installed → Install, stopped → Start, errored → Restart + Reset, installing/starting → spinner, cli_missing/unhealthy → Reinstall CLI, running → optional Stop. extras slot for adapter-specific affordances (e.g. openclaw provider Setup dialog trigger). --- .../runtime-controls/RuntimeControlPanel.tsx | 231 ++++++++++++++++++ .../runtime-controls/RuntimeStatusBar.tsx | 168 +++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeStatusBar.tsx diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx new file mode 100644 index 000000000..b67fe1a76 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx @@ -0,0 +1,231 @@ +import { + Download, + Loader2, + Play, + RotateCcw, + Square, + TriangleAlert, +} from 'lucide-react' +import type { FC, ReactNode } from 'react' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + type RuntimeAction, + type RuntimeAdapterId, + useRuntime, + useRuntimeAction, +} from '../useRuntime' + +interface RuntimeControlPanelProps { + adapter: RuntimeAdapterId + /** Optional — adapter-specific extras rendered below the primary CTA (e.g. openclaw provider config dialog trigger). */ + extras?: ReactNode +} + +/** + * State-appropriate primary CTAs for a runtime, gated on capabilities. + * Container runtimes get install/start/stop/restart; host-process + * runtimes get reinstall-cli/check-auth. + */ +export const RuntimeControlPanel: FC = ({ + adapter, + extras, +}) => { + const { data, isLoading } = useRuntime(adapter) + const action = useRuntimeAction(adapter) + if (isLoading || !data) return null + + const { state } = data.status + const caps = new Set(data.capabilities) + const acting = action.isPending + const dispatch = (a: RuntimeAction) => action.mutate({ action: a }) + + // Container-runtime states first (most adapters today). + if (state === 'not_installed' && caps.has('install')) + return ( + + } + label="Install" + onClick={() => dispatch('install')} + acting={acting} + /> + {extras} + + ) + + if (state === 'stopped' && caps.has('start')) + return ( + + } + label="Start" + onClick={() => dispatch('start')} + acting={acting} + /> + {extras} + + ) + + if (state === 'errored') + return ( + + {caps.has('restart') && ( + } + label="Restart" + onClick={() => dispatch('restart')} + acting={acting} + /> + )} + {caps.has('reset-soft') && ( + + )} + {extras} + + ) + + if (state === 'installing' || state === 'starting') + return ( + + + {extras} + + ) + + // Host-process runtime states. + if (state === 'cli_missing' && caps.has('reinstall-cli')) + return ( + + } + label="Reinstall CLI" + onClick={() => dispatch('reinstall-cli')} + acting={acting} + /> + {extras} + + ) + + if (state === 'cli_unhealthy' && caps.has('reinstall-cli')) + return ( + + } + label="Reinstall CLI" + onClick={() => dispatch('reinstall-cli')} + acting={acting} + /> + {extras} + + ) + + // No CTA needed when running / cli_present — the StatusBar shows + // the running pill. Optional Stop appears in the status-bar slot. + if (state === 'running' && caps.has('stop')) + return extras ? ( +
+ + {extras} +
+ ) : null + + return null +} + +interface PrimaryProps { + icon: ReactNode + label: string + onClick: () => void + acting: boolean +} + +const Primary: FC = ({ icon, label, onClick, acting }) => ( + +) + +interface PanelCardProps { + title: string + description?: string + tone?: 'default' | 'destructive' | 'muted' + children: ReactNode +} + +const PanelCard: FC = ({ + title, + description, + tone = 'default', + children, +}) => ( + + + {title} + {description && ( + {description} + )} + + + {children} + + +) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeStatusBar.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeStatusBar.tsx new file mode 100644 index 000000000..cd4b77d2f --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeStatusBar.tsx @@ -0,0 +1,168 @@ +import { Loader2, RotateCcw } from 'lucide-react' +import type { FC, ReactNode } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { + type RuntimeAdapterId, + useRuntime, + useRuntimeAction, +} from '../useRuntime' + +interface RuntimeStatusBarProps { + adapter: RuntimeAdapterId + /** Optional — render an adapter-specific extra pill (e.g. control-plane status for openclaw). */ + extraPill?: ReactNode + /** Optional — slot rendered after the restart button (e.g. "Open Terminal" for openclaw). */ + extraActions?: ReactNode +} + +export const RuntimeStatusBar: FC = ({ + adapter, + extraPill, + extraActions, +}) => { + const { data, isLoading } = useRuntime(adapter) + const restart = useRuntimeAction(adapter) + + if (isLoading || !data) return null + + const pill = pillForState(data.status.state) + const canRestart = data.capabilities.includes('restart') + const acting = restart.isPending + + return ( +
+
+ + {data.descriptor.displayName} + + + + {pill.label} + + {extraPill} + {(canRestart || extraActions) && ( + + )} + {extraActions} + {canRestart && ( + + + + )} +
+ {data.status.lastError && data.status.state === 'errored' && ( +

{data.status.lastError}

+ )} +
+ ) +} + +const WithTooltip: FC<{ label: string; children: ReactNode }> = ({ + label, + children, +}) => ( + + + {children} + + {label} + + + +) + +interface PillKind { + variant: 'default' | 'secondary' | 'outline' | 'destructive' + label: string + dot: string + className?: string +} + +function pillForState(state: string): PillKind { + switch (state) { + case 'running': + case 'cli_present': + return { + variant: 'secondary', + label: state === 'cli_present' ? 'Available' : 'Running', + dot: 'bg-emerald-500', + className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50', + } + case 'starting': + case 'installing': + return { + variant: 'secondary', + label: state === 'installing' ? 'Installing' : 'Starting', + dot: 'bg-amber-500 animate-pulse', + className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', + } + case 'installed': + case 'stopped': + return { + variant: 'outline', + label: state === 'installed' ? 'Installed' : 'Stopped', + dot: 'bg-muted-foreground/40', + } + case 'cli_missing': + return { + variant: 'outline', + label: 'CLI not installed', + dot: 'bg-amber-500', + className: 'border-amber-500/40 bg-amber-50 text-amber-900', + } + case 'cli_unhealthy': + return { + variant: 'destructive', + label: 'CLI unhealthy', + dot: 'bg-destructive-foreground', + } + case 'errored': + return { + variant: 'destructive', + label: 'Errored', + dot: 'bg-destructive-foreground', + } + case 'unsupported_platform': + return { + variant: 'outline', + label: 'Unsupported platform', + dot: 'bg-muted-foreground/40', + } + case 'not_installed': + return { + variant: 'outline', + label: 'Not installed', + dot: 'bg-muted-foreground/40', + } + default: + return { + variant: 'outline', + label: state, + dot: 'bg-muted-foreground/40', + } + } +} From c099a35deedf4844e8325f842c49050b65fd0a85 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 19:20:40 +0530 Subject: [PATCH 04/15] refactor(ui): wire RuntimeStatusBar + RuntimeControlPanel on AgentsPage; drop legacy lifecycle UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentsPage now uses the new runtime-control components for OpenClaw lifecycle: - RuntimeControlPanel replaces GatewayStateCards (state-appropriate CTAs gated on capabilities). Provider config dialog trigger lives in the panel's extras slot. - RuntimeStatusBar replaces GatewayStatusBar (running pill + Restart). Control-plane pill + Open Terminal live in the bar's extra slots — gateway specifics stay outside the runtime layer. GatewayStatusBar.tsx deletes outright. The 'Unavailable' badge in AgentSummaryChips.tsx deletes — capabilities-driven UI surfaces the same signal more usefully on the new RuntimeControlPanel; the prop stays for upstream callers but is now a no-op. ControlPlaneAlert / LifecycleAlert / InlineErrorAlert from OpenClawControls remain — they're alerts for control-plane and mid-flight lifecycle states, distinct from the runtime control surface. They cover gateway-specific concerns the runtime layer doesn't model. Cleanup deferred to a follow-up. --- .../entrypoints/app/agents/AgentsPage.tsx | 107 +++++++-- .../app/agents/GatewayStatusBar.tsx | 206 ------------------ .../agents/agent-row/AgentSummaryChips.tsx | 52 +---- 3 files changed, 91 insertions(+), 274 deletions(-) delete mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/GatewayStatusBar.tsx diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx index 91faca66f..a3891229e 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -1,7 +1,10 @@ -import { Loader2 } from 'lucide-react' +import { Loader2, Terminal as TerminalIcon } from 'lucide-react' import { type FC, useMemo, useState } from 'react' import { useNavigate } from 'react-router' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' +import { cn } from '@/lib/utils' import { AgentList } from './AgentList' import { AgentsHeader } from './AgentsHeader' import { AgentTerminal } from './AgentTerminal' @@ -31,14 +34,14 @@ import { toHarnessListItem, toOpenClawListItem, } from './agents-page-utils' -import { GatewayStatusBar } from './GatewayStatusBar' import { NewAgentDialog } from './NewAgentDialog' import { ControlPlaneAlert, - GatewayStateCards, InlineErrorAlert, LifecycleAlert, } from './OpenClawControls' +import { RuntimeControlPanel } from './runtime-controls/RuntimeControlPanel' +import { RuntimeStatusBar } from './runtime-controls/RuntimeStatusBar' import { SetupOpenClawDialog } from './SetupOpenClawDialog' import { useAgentAdapters, @@ -83,7 +86,6 @@ export const AgentsPage: FC = () => { setupOpenClaw, createAgent: createOpenClawAgent, deleteAgent: deleteOpenClawAgent, - startOpenClaw, restartOpenClaw, reconnectOpenClaw, actionInProgress, @@ -329,26 +331,39 @@ export const AgentsPage: FC = () => { /> ) : null} - setSetupOpen(true)} - onRestart={() => { - void runWithPageErrorHandling(restartOpenClaw) - }} - onStart={() => { - void runWithPageErrorHandling(startOpenClaw) - }} + setSetupOpen(true)} + > + Configure provider… + + ) : null + } /> {showGatewayStatusBar ? ( - setShowTerminal(true)} - onRestart={() => { - void runWithPageErrorHandling(restartOpenClaw) - }} + + ) : null + } + extraActions={ + + } /> ) : null} @@ -430,3 +445,53 @@ export const AgentsPage: FC = () => { ) } + +const ControlPlanePill: FC<{ status: string }> = ({ status }) => { + const pill = pillForControlPlane(status) + return ( + + + {pill.label} + + ) +} + +interface ControlPlanePillKind { + variant: 'default' | 'secondary' | 'outline' | 'destructive' + label: string + dot: string + className?: string +} + +function pillForControlPlane(status: string): ControlPlanePillKind { + switch (status) { + case 'connected': + return { + variant: 'secondary', + label: 'Control plane connected', + dot: 'bg-emerald-500', + className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50', + } + case 'connecting': + case 'reconnecting': + case 'recovering': + return { + variant: 'secondary', + label: 'Connecting', + dot: 'bg-amber-500 animate-pulse', + className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', + } + case 'failed': + return { + variant: 'destructive', + label: 'Needs attention', + dot: 'bg-destructive-foreground', + } + default: + return { + variant: 'outline', + label: 'Disconnected', + dot: 'bg-muted-foreground/40', + } + } +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/GatewayStatusBar.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/GatewayStatusBar.tsx deleted file mode 100644 index 1da29aa48..000000000 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/GatewayStatusBar.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { Loader2, RotateCcw, Terminal } from 'lucide-react' -import type { FC, ReactNode } from 'react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Separator } from '@/components/ui/separator' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import type { OpenClawStatus } from './useOpenClaw' - -interface GatewayStatusBarProps { - status: OpenClawStatus | null - /** Disabled while a gateway lifecycle mutation is mid-flight. */ - actionInProgress: boolean - onOpenTerminal: () => void - onRestart: () => void -} - -/** - * Compact one-line status bar for the OpenClaw gateway. Renders the - * lifecycle pills (Running / Control plane connected) plus a Terminal - * escape hatch and a Restart Gateway action. Lives between the page - * header and the agent list when at least one OpenClaw agent is in - * the merged list; collapses to nothing for Claude/Codex-only setups. - * - * Status is sourced from `GET /agents`'s `gateway` field — the agents - * page no longer polls `/claw/status` directly. One endpoint, one - * 5s interval, no duplicate state. - */ -export const GatewayStatusBar: FC = ({ - status, - actionInProgress, - onOpenTerminal, - onRestart, -}) => { - if (!status) return null - - const runningPill = pillForRuntimeStatus(status.status) - const controlPlanePill = pillForControlPlane(status.controlPlaneStatus) - - return ( -
-
- - OpenClaw gateway - - - - {runningPill.label} - - - - {controlPlanePill.label} - - - - - - - - -
-
- ) -} - -const WithTooltip: FC<{ label: string; children: ReactNode }> = ({ - label, - children, -}) => ( - - - {children} - - {label} - - - -) - -type PillKind = { - variant: 'default' | 'secondary' | 'outline' | 'destructive' - label: string - dot: string - className?: string -} - -function pillForRuntimeStatus(status: OpenClawStatus['status']): PillKind { - switch (status) { - case 'running': - return { - variant: 'secondary', - label: 'Running', - dot: 'bg-emerald-500', - className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50', - } - case 'starting': - return { - variant: 'secondary', - label: 'Starting', - dot: 'bg-amber-500 animate-pulse', - className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', - } - case 'stopped': - return { - variant: 'outline', - label: 'Stopped', - dot: 'bg-muted-foreground/40', - } - case 'error': - return { - variant: 'destructive', - label: 'Error', - dot: 'bg-destructive-foreground', - } - default: - return { - variant: 'outline', - label: 'Unknown', - dot: 'bg-muted-foreground/40', - } - } -} - -function pillForControlPlane( - status: OpenClawStatus['controlPlaneStatus'], -): PillKind { - switch (status) { - case 'connected': - return { - variant: 'secondary', - label: 'Control plane connected', - dot: 'bg-emerald-500', - className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50', - } - case 'connecting': - return { - variant: 'secondary', - label: 'Connecting', - dot: 'bg-amber-500 animate-pulse', - className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', - } - case 'reconnecting': - return { - variant: 'secondary', - label: 'Reconnecting', - dot: 'bg-amber-500 animate-pulse', - className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', - } - case 'recovering': - return { - variant: 'secondary', - label: 'Recovering', - dot: 'bg-amber-500 animate-pulse', - className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', - } - case 'failed': - return { - variant: 'destructive', - label: 'Needs attention', - dot: 'bg-destructive-foreground', - } - default: - return { - variant: 'outline', - label: 'Disconnected', - dot: 'bg-muted-foreground/40', - } - } -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-row/AgentSummaryChips.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-row/AgentSummaryChips.tsx index a7b29843f..1baabd758 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-row/AgentSummaryChips.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-row/AgentSummaryChips.tsx @@ -1,12 +1,4 @@ -import { TriangleAlert } from 'lucide-react' import type { FC } from 'react' -import { Badge } from '@/components/ui/badge' -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from '@/components/ui/hover-card' -import { cn } from '@/lib/utils' import { adapterLabel } from '../AdapterIcon' import type { HarnessAgentAdapter } from '../agent-harness-types' import type { AgentAdapterHealth } from './agent-row.types' @@ -15,57 +7,23 @@ interface AgentSummaryChipsProps { adapter: HarnessAgentAdapter | 'unknown' modelLabel: string | null reasoningEffort: string | null - /** When unhealthy, the adapter label dims and a warning chip appears. */ - adapterHealth: AgentAdapterHealth | null + /** Retained for upstream callers; per-adapter availability is now + * signalled via the runtime control panel, not this row chip. */ + adapterHealth?: AgentAdapterHealth | null } -/** - * Adapter / model / reasoning summary line. Always rendered (so OpenClaw - * rows that fall back to defaults still expose what they're set up to do) - * and surfaces adapter-health *only when unhealthy* — keeping the calm - * default state silent and reserving visual noise for things the user - * needs to act on. - */ +/** Adapter / model / reasoning summary line on an agent row. */ export const AgentSummaryChips: FC = ({ adapter, modelLabel, reasoningEffort, - adapterHealth, }) => { const parts = [adapterLabel(adapter)] if (modelLabel) parts.push(modelLabel) if (reasoningEffort) parts.push(reasoningEffort) - const unhealthy = adapterHealth?.healthy === false return ( -
+
{parts.join(' · ')} - {unhealthy && adapterHealth && ( - - - - - Unavailable - - - -
- {adapterLabel(adapter)} CLI not available -
-
- {adapterHealth.reason ?? - 'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'} -
-
-
- )}
) } From ab63827b691a3a20f1c170ce813cec139413228c Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 19:47:27 +0530 Subject: [PATCH 05/15] fix(openclaw): sync runtime state from existing container at boot; render Start CTA for installed state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stuck-state bugs in the new RuntimeControlPanel: 1. The runtime's state machine started fresh at not_installed on every server boot. tryAutoStart's short-circuit branches (gateway already running, auth pass) never drove the state transitions, so the UI saw not_installed for a gateway that was actually running. Add a syncState() method on OpenClawContainerRuntime that probes the actual container via cli.inspectContainer + /readyz and sets state accordingly. Wire it into tryAutoStart as the first step so it runs regardless of which branch the rest takes. 2. RuntimeControlPanel had no case for state === 'installed', so after a successful Install the panel went blank instead of offering the next step. Treat installed the same as stopped — show the Start CTA with copy that reflects the difference (image is pulled vs container exists but stopped). Optional-chained the syncState call so existing tests with partial runtime mocks don't crash on the missing method. --- .../runtime-controls/RuntimeControlPanel.tsx | 10 +++++-- .../api/services/openclaw/openclaw-service.ts | 13 ++++++++ .../runtime/openclaw-container-runtime.ts | 30 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx index b67fe1a76..d0e582e6d 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx @@ -63,11 +63,15 @@ export const RuntimeControlPanel: FC = ({ ) - if (state === 'stopped' && caps.has('start')) + if ((state === 'stopped' || state === 'installed') && caps.has('start')) return ( } diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index 1b2f0e8a2..f14c502ac 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -1123,6 +1123,13 @@ export class OpenClawService { async tryAutoStart(): Promise { return this.withLifecycleLock('auto-start', async () => { + // Sync first so the UI sees an accurate state even when the + // gateway is already running from a previous server process + // and we'd otherwise short-circuit later. Optional-chained so + // tests that mock `service.runtime` with a partial fake don't + // crash here. + await this.runtime.syncState?.() + const isSetUp = existsSync(this.getStateConfigPath()) if (!isSetUp) return @@ -1153,6 +1160,12 @@ export class OpenClawService { } } + // Sync the runtime's state machine to whatever the actual + // container is doing — short-circuit branches above don't + // drive the state transitions, so without this the UI sees + // `not_installed` for a gateway that's actually running. + await this.runtime.syncState?.() + await this.runControlPlaneCall(() => this.cliClient.probe()) await this.ensureAllCliProvidersInstalled() logger.info('OpenClaw gateway auto-started') diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts index 66800a029..4c54ed221 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts @@ -236,6 +236,36 @@ export class OpenClawContainerRuntime extends ContainerAgentRuntime { return this.readinessProbe() } + /** Sync internal state from the actual container — used at boot + * when the gateway may already be running from a previous server + * process and the runtime's state machine starts fresh. Without + * this, the UI sees `not_installed` for an actively-running + * gateway because nothing has driven the state transitions. */ + async syncState(): Promise { + try { + const info = await this.deps.cli.inspectContainer( + this.descriptor.containerName, + ) + if (!info) { + if (this.state !== 'not_installed') this.setState('not_installed') + return + } + if (info.running) { + if (await fetchOk(`http://127.0.0.1:${this.hostPort}/readyz`)) { + this.setState('running') + return + } + this.setState('starting') + return + } + this.setState('stopped') + } catch (err) { + logger.warn('OpenClaw runtime syncState failed', { + error: err instanceof Error ? err.message : String(err), + }) + } + } + // ── Service-facing compat surface ──────────────────────────────── // These wrap inherited lifecycle methods using the legacy method // names OpenClawService still uses. Keeping them lets the service From 4ccb7ac0fd64d781cc2f0a1c4b56cde5cb12d9d6 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 21:01:10 +0530 Subject: [PATCH 06/15] fix(openclaw): reconcile drifted gateway host port from live container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a previous server boot wrote runtime-state.json after the gateway container had already been created with a different hostPort (e.g. 18789 held at allocate-time → container started on 18790), the persisted port disagrees with the live mapping. The runtime then probes the persisted port forever and the UI sticks at `starting`. `syncState` now reads `NetworkSettings.Ports` from inspect-container and adopts the actual host port for the gateway container's published port when it differs. The service then re-syncs `hostPort`/`httpClient` and rewrites runtime-state.json so the next boot starts from a clean slate. - ContainerInfo gains a flat `ports` array (parsed from `NetworkSettings.Ports`) - OpenClawContainerRuntime.syncState: reconcile hostPort from live mapping before probing /readyz - OpenClawService.tryAutoStart: adopt the runtime's reconciled port and persist it via writePersistedGatewayPort --- .../api/services/openclaw/openclaw-service.ts | 33 +++++++++++- .../api/services/openclaw/runtime-state.ts | 2 +- .../runtime/openclaw-container-runtime.ts | 19 +++++-- .../server/src/lib/container/container-cli.ts | 28 ++++++++++ .../apps/server/src/lib/container/types.ts | 9 ++++ .../runtime/hermes-container-runtime.test.ts | 1 + .../openclaw-container-runtime.test.ts | 52 +++++++++++++++++++ .../tests/lib/container/container-cli.test.ts | 31 +++++++++++ .../managed/managed-container.test.ts | 1 + 9 files changed, 171 insertions(+), 5 deletions(-) diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index f14c502ac..ba612b74c 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -69,7 +69,11 @@ import { type ResolvedOpenClawProviderConfig, resolveSupportedOpenClawProvider, } from './openclaw-provider-map' -import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state' +import { + allocateGatewayPort, + readPersistedGatewayPort, + writePersistedGatewayPort, +} from './runtime-state' const READY_TIMEOUT_MS = 30_000 const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/ @@ -1129,6 +1133,7 @@ export class OpenClawService { // tests that mock `service.runtime` with a partial fake don't // crash here. await this.runtime.syncState?.() + await this.adoptRuntimeHostPort() const isSetUp = existsSync(this.getStateConfigPath()) if (!isSetUp) return @@ -1165,6 +1170,7 @@ export class OpenClawService { // drive the state transitions, so without this the UI sees // `not_installed` for a gateway that's actually running. await this.runtime.syncState?.() + await this.adoptRuntimeHostPort() await this.runControlPlaneCall(() => this.cliClient.probe()) await this.ensureAllCliProvidersInstalled() @@ -1267,6 +1273,31 @@ export class OpenClawService { this.httpClient = new OpenClawHttpClient(this.hostPort) } + /** + * If runtime.syncState reconciled the host port from the live + * container mapping, adopt it on the service side and rewrite + * runtime-state.json so subsequent boots don't drift again. + */ + private async adoptRuntimeHostPort(): Promise { + const runtimePort = this.runtime.getHostPort?.() + if (typeof runtimePort !== 'number' || runtimePort === this.hostPort) { + return + } + logger.info('Adopting reconciled OpenClaw gateway host port', { + previous: this.hostPort, + actual: runtimePort, + }) + this.setPort(runtimePort) + try { + await writePersistedGatewayPort(this.openclawDir, runtimePort) + } catch (err) { + logger.warn('Failed to persist reconciled OpenClaw gateway port', { + port: runtimePort, + error: err instanceof Error ? err.message : String(err), + }) + } + } + private async ensureGatewayPortAllocated( logProgress?: (msg: string) => void, ): Promise { diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts index 2df49639d..b6354d095 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts @@ -60,7 +60,7 @@ export async function readPersistedGatewayPort( } } -async function writePersistedGatewayPort( +export async function writePersistedGatewayPort( openclawDir: string, port: number, ): Promise { diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts index 4c54ed221..60652fff0 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts @@ -238,9 +238,10 @@ export class OpenClawContainerRuntime extends ContainerAgentRuntime { /** Sync internal state from the actual container — used at boot * when the gateway may already be running from a previous server - * process and the runtime's state machine starts fresh. Without - * this, the UI sees `not_installed` for an actively-running - * gateway because nothing has driven the state transitions. */ + * process and the runtime's state machine starts fresh. Also + * reconciles `hostPort` against the live port mapping when the + * persisted runtime-state.json drifted from what the container + * was actually started with. */ async syncState(): Promise { try { const info = await this.deps.cli.inspectContainer( @@ -251,6 +252,18 @@ export class OpenClawContainerRuntime extends ContainerAgentRuntime { return } if (info.running) { + const mapped = info.ports.find( + (p) => + p.containerPort === OPENCLAW_GATEWAY_CONTAINER_PORT && + p.protocol === 'tcp', + ) + if (mapped && mapped.hostPort !== this.hostPort) { + logger.info('OpenClaw runtime host port reconciled from container', { + previous: this.hostPort, + actual: mapped.hostPort, + }) + this.hostPort = mapped.hostPort + } if (await fetchOk(`http://127.0.0.1:${this.hostPort}/readyz`)) { this.setState('running') return diff --git a/packages/browseros-agent/apps/server/src/lib/container/container-cli.ts b/packages/browseros-agent/apps/server/src/lib/container/container-cli.ts index d4e6b4fdd..3bbd1f0d5 100644 --- a/packages/browseros-agent/apps/server/src/lib/container/container-cli.ts +++ b/packages/browseros-agent/apps/server/src/lib/container/container-cli.ts @@ -13,6 +13,7 @@ import { import { LimaCli } from '../vm/lima-cli' import type { ContainerInfo, + ContainerPortMapping, ContainerSpec, LogFn, MountSpec, @@ -300,6 +301,9 @@ function parseContainerInfo( const object = isRecord(container) ? container : {} const config = isRecord(object.Config) ? object.Config : {} const state = isRecord(object.State) ? object.State : {} + const networkSettings = isRecord(object.NetworkSettings) + ? object.NetworkSettings + : {} const name = stringValue(object.Name)?.replace(/^\/+/, '') ?? fallbackName const status = stringValue(state.Status) ?? stringValue(object.Status) const running = @@ -315,9 +319,33 @@ function parseContainerInfo( image: stringValue(config.Image) ?? stringValue(object.Image), status, running, + ports: parsePortMappings(networkSettings.Ports), } } +function parsePortMappings(raw: unknown): ContainerPortMapping[] { + if (!isRecord(raw)) return [] + const mappings: ContainerPortMapping[] = [] + for (const [key, value] of Object.entries(raw)) { + const [portPart, protocol = 'tcp'] = key.split('/') + const containerPort = Number.parseInt(portPart ?? '', 10) + if (!Number.isInteger(containerPort)) continue + if (!Array.isArray(value)) continue + for (const binding of value) { + if (!isRecord(binding)) continue + const hostPort = Number.parseInt(stringValue(binding.HostPort) ?? '', 10) + if (!Number.isInteger(hostPort)) continue + mappings.push({ + containerPort, + protocol, + hostIp: stringValue(binding.HostIp) ?? null, + hostPort, + }) + } + } + return mappings +} + export function isNoSuchContainer(stderr: string): boolean { const lower = stderr.toLowerCase() return ( diff --git a/packages/browseros-agent/apps/server/src/lib/container/types.ts b/packages/browseros-agent/apps/server/src/lib/container/types.ts index 9ca6f83d3..ef40708a6 100644 --- a/packages/browseros-agent/apps/server/src/lib/container/types.ts +++ b/packages/browseros-agent/apps/server/src/lib/container/types.ts @@ -46,12 +46,21 @@ export interface ContainerSpec { command?: string[] } +export interface ContainerPortMapping { + containerPort: number + protocol: string + hostIp: string | null + hostPort: number +} + export interface ContainerInfo { id: string | null name: string image: string | null status: string | null running: boolean | null + /** Flat view of `NetworkSettings.Ports`. Empty if the container has no published ports. */ + ports: ContainerPortMapping[] } export interface WaitForContainerNameReleaseOptions { diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts index 09fc72f49..fe792fc30 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts @@ -54,6 +54,7 @@ function makeDeps(opts: { image: HERMES_IMAGE, status: 'running', running: true, + ports: [], }), removeContainer: async () => {}, waitForContainerNameRelease: async () => {}, diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts index b4987c46c..d7d8a6a0a 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts @@ -50,6 +50,7 @@ function makeDeps(opts: { lockDir: string }): { image: 'docker.io/openclaw:latest', status: 'running', running: true, + ports: [], }), removeContainer: async () => {}, waitForContainerNameRelease: async () => {}, @@ -226,6 +227,57 @@ describe('OpenClawContainerRuntime', () => { expect(out).toContain('--session agent:main:main') }) + it('syncState adopts the actual host port when persisted port drifted', async () => { + const lockDir = mkTempDir() + const browserosDir = '/host/browseros' + const fakeCli = { + inspectContainer: async (): Promise => ({ + id: 'cid', + name: OPENCLAW_GATEWAY_CONTAINER_NAME, + image: 'docker.io/openclaw:latest', + status: 'running', + running: true, + ports: [ + { + containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT, + protocol: 'tcp', + hostIp: '127.0.0.1', + hostPort: 18790, + }, + ], + }), + removeContainer: async () => {}, + waitForContainerNameRelease: async () => {}, + createContainer: async () => {}, + startContainer: async () => {}, + waitForContainerRunning: async () => {}, + exec: async () => 0, + } + const deps: ManagedContainerDeps = { + cli: fakeCli as unknown as ManagedContainerDeps['cli'], + loader: { + ensureImageLoaded: async () => {}, + } as ManagedContainerDeps['loader'], + vm: { + ensureReady: async () => {}, + getDefaultGateway: async () => '192.168.5.2', + isReady: async () => true, + stopVm: async () => {}, + } as unknown as ManagedContainerDeps['vm'], + limactlPath: '/opt/homebrew/bin/limactl', + limaHome: '/Users/dev/.browseros/lima', + vmName: 'browseros-vm', + lockDir, + } + const runtime = new OpenClawContainerRuntime(deps, { + browserosDir, + openclawDir: `${browserosDir}/vm/openclaw`, + }) + runtime.setHostPort(18789) + await runtime.syncState() + expect(runtime.getHostPort()).toBe(18790) + }) + it('compat methods delegate to inherited base primitives', () => { const { runtime } = makeRuntime() // Just verifying these don't throw and that the names exist — diff --git a/packages/browseros-agent/apps/server/tests/lib/container/container-cli.test.ts b/packages/browseros-agent/apps/server/tests/lib/container/container-cli.test.ts index 0cd6633c7..87e8b1691 100644 --- a/packages/browseros-agent/apps/server/tests/lib/container/container-cli.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/container/container-cli.test.ts @@ -193,6 +193,7 @@ describe('ContainerCli', () => { image: 'openclaw:v1', status: 'running', running: true, + ports: [], }) await expect(readFile(logPath, 'utf8')).resolves.toContain( @@ -200,6 +201,36 @@ describe('ContainerCli', () => { ) }) + it('parses NetworkSettings.Ports into a flat ports array', async () => { + const sshPath = await fakeSsh( + { + stdout: JSON.stringify({ + ID: 'abc123', + Name: 'gateway', + Config: { Image: 'openclaw:v1' }, + State: { Status: 'running', Running: true }, + NetworkSettings: { + Ports: { + '18789/tcp': [{ HostIp: '127.0.0.1', HostPort: '18790' }], + }, + }, + }), + }, + logPath, + ) + const cli = await createCli(sshPath, tempDir) + + const info = await cli.inspectContainer('gateway') + expect(info?.ports).toEqual([ + { + containerPort: 18789, + protocol: 'tcp', + hostIp: '127.0.0.1', + hostPort: 18790, + }, + ]) + }) + it('returns null when inspected containers are absent', async () => { const sshPath = await fakeSsh( { stderr: 'no such container', exit: 1 }, diff --git a/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts b/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts index 712dee267..f16ad0344 100644 --- a/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts @@ -96,6 +96,7 @@ function makeFakeDeps(opts: { lockDir: string }): ManagedContainerDeps & { image: 'docker.io/test:latest', status: 'running', running: true, + ports: [], }), removeContainer: async () => {}, waitForContainerNameRelease: async () => {}, From 830eebae82b3cc7b65e91962fdfa4d3300145d24 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 22:28:56 +0530 Subject: [PATCH 07/15] fix(openclaw): stop stale gateway before re-allocating port on auth mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a previous boot leaves a gateway running with a stale token, the realloc-on-auth-mismatch branch was bumping the persisted port without actually freeing the old container — ManagedContainer.start() no-ops when state==='running', so the next start cycle never recreated the container on the new port. The result: persisted/service/runtime drift back into mismatch, and history requests 500 with "gateway is not ready" even while the (stale) gateway keeps serving chat from the old port. Stop the gateway explicitly when we decide to bump off the port, so the upcoming start cycle goes through the full remove + create + start path on the freshly-allocated port. The token-mismatch test still passes; adds a new test pinning the stop-before-realloc behaviour. --- .../api/services/openclaw/openclaw-service.ts | 19 ++++++++++ .../openclaw/openclaw-service.test.ts | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index ba612b74c..53cf01c42 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -1312,6 +1312,25 @@ export class OpenClawService { ) { return } + if (currentPortReady) { + // Port is reachable but auth rejected — a stale gateway from a + // previous boot or token rotation owns it. Stop our container + // first so the upcoming start cycle actually creates a fresh + // one: ManagedContainer.start no-ops when state==='running', + // so without this the realloc would bump the persisted port + // while leaving the old container still bound to the old one. + logProgress?.('Stopping stale OpenClaw gateway before re-allocating port') + logger.info('Stopping stale OpenClaw gateway before re-allocating port', { + hostPort: this.hostPort, + }) + try { + await this.runtime.stopGateway?.() + } catch (err) { + logger.warn('Failed to stop stale OpenClaw gateway before realloc', { + error: err instanceof Error ? err.message : String(err), + }) + } + } const hostPort = await allocateGatewayPort(this.openclawDir, { excludePort: currentPortReady ? this.hostPort : undefined, }) diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index e9d337290..1d6b91b16 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -900,6 +900,43 @@ describe('OpenClawService', () => { expect(ensureReady).toHaveBeenCalledTimes(1) }) + it('stops the stale gateway before re-allocating port on auth mismatch', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await mkdir(join(tempDir, '.openclaw'), { recursive: true }) + await writeFile( + join(tempDir, '.openclaw', 'openclaw.json'), + JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }), + ) + const occupiedPort = getSyntheticOccupiedPort() + await writeFile( + join(tempDir, '.openclaw', 'runtime-state.json'), + `${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`, + ) + const ensureReady = mock(async () => {}) + const restartGateway = mock(async () => {}) + const stopGateway = mock(async () => {}) + const waitForReady = mock(async () => true) + const probe = mock(async () => {}) + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + service.runtime = { + ensureReady, + isReady: async () => true, + restartGateway, + stopGateway, + waitForReady, + } + service.cliClient = { probe } + mockGatewayAuth(401) + + await service.restart() + + expect(stopGateway).toHaveBeenCalledTimes(1) + expect(restartGateway).toHaveBeenCalledTimes(1) + expect(service.getPort()).not.toBe(occupiedPort) + }) + it('stop calls runtime.stopGateway', async () => { const stopGateway = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService From 349c3743a9ff360ad7c0ef03ad771f0116c11fd8 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Fri, 8 May 2026 23:22:57 +0530 Subject: [PATCH 08/15] fix(openclaw): seed empty .env in runtime so direct Start works on a fresh install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting the gateway via the new RuntimeControlPanel "Start" CTA goes through runtime.executeAction({type:'start'}) directly, bypassing OpenClawService.tryAutoStart and its ensureStateEnvFile() seeding step. On a freshly-wiped .browseros-dev that left nerdctl create failing with "failed to open env file .../.openclaw/.env: no such file or directory". Seed the file (empty, mode 0600) inside buildContainerSpec so the runtime is self-sufficient. Service callers continue to work — their ensureStateEnvFile is now an idempotent no-op once the file exists. --- .../agents/runtime/openclaw-container-runtime.ts | 13 ++++++++++++- .../runtime/openclaw-container-runtime.test.ts | 14 ++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts index 60652fff0..279c57152 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { join } from 'node:path' +import { existsSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' import { OPENCLAW_GATEWAY_CONTAINER_NAME, OPENCLAW_GATEWAY_CONTAINER_PORT, @@ -112,6 +114,15 @@ export class OpenClawContainerRuntime extends ContainerAgentRuntime { protected async buildContainerSpec(): Promise { const hostPort = this.hostPort const envFilePath = getOpenClawStateEnvPath(this.openclawConfig.openclawDir) + // OpenClawService normally seeds this during its setup flow, but + // starting via the runtime directly (RuntimeControlPanel "Start" + // CTA on a fresh install) bypasses that path, so nerdctl --env-file + // would crash on the missing file. Touch it here so the runtime is + // self-sufficient. + if (!existsSync(envFilePath)) { + await mkdir(dirname(envFilePath), { recursive: true }) + await writeFile(envFilePath, '', { mode: 0o600 }) + } const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const gateway = await this.deps.vm.getDefaultGateway() return { diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts index d7d8a6a0a..e9623ecd4 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts @@ -106,13 +106,11 @@ describe('OpenClawContainerRuntime', () => { function makeRuntime() { const lockDir = mkTempDir() - const browserosDir = '/host/browseros' + const browserosDir = mkTempDir() + const openclawDir = join(browserosDir, 'vm/openclaw') const { deps, getCapturedSpec } = makeDeps({ lockDir }) - const runtime = new TestRuntime(deps, { - browserosDir, - openclawDir: `${browserosDir}/vm/openclaw`, - }) - return { runtime, getCapturedSpec, browserosDir } + const runtime = new TestRuntime(deps, { browserosDir, openclawDir }) + return { runtime, getCapturedSpec, browserosDir, openclawDir } } it('declares the canonical OpenClaw runtime descriptor', () => { @@ -126,13 +124,13 @@ describe('OpenClawContainerRuntime', () => { }) it('mountRoots maps the openclaw state dir to the gateway container home', () => { - const { runtime } = makeRuntime() + const { runtime, openclawDir } = makeRuntime() const mounts: readonly MountRoot[] = ( runtime as unknown as { mountRoots(): readonly MountRoot[] } ).mountRoots() expect(mounts).toEqual([ { - hostPath: '/host/browseros/vm/openclaw', + hostPath: openclawDir, containerPath: '/home/node', kind: 'shared', }, From d6440bdccd38ad914b8ca8105ad1756d78992cf3 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 16:32:41 +0530 Subject: [PATCH 09/15] refactor(openclaw): derive legacy gateway status from runtime state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClawService.getStatus was carrying its own view of "is the gateway alive" (running/stopped/uninitialized derived from machineStatus + isReady probe) while the new AgentRuntime maintains the canonical state machine. The two could disagree — most visibly after a wipe + partial restart, where the runtime correctly read not_installed but the service still reported running/connected from in-memory fields. Map the legacy status surface from runtime.getStatusSnapshot().state so both pills can't contradict each other. Clear controlPlaneStatus / lastGatewayError / lastRecoveryReason whenever the runtime isn't running — those signals are only meaningful for an alive gateway. First chunk of the legacy-lifecycle removal. Lifecycle methods on the service (restart/shutdown/tryAutoStart/etc.) and duplicated hostPort state still exist and will be removed in follow-up commits. --- .../api/services/openclaw/openclaw-service.ts | 51 +++++++++++++++---- .../openclaw/openclaw-service.test.ts | 28 ++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index 53cf01c42..910cfb1aa 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -105,6 +105,28 @@ export type OpenClawStatus = | 'stopped' | 'error' +function mapRuntimeStateToLegacy( + state: string | null, + lastError: string | null, +): OpenClawStatus { + switch (state) { + case 'not_installed': + return 'uninitialized' + case 'installing': + case 'starting': + return 'starting' + case 'running': + return 'running' + case 'errored': + return 'error' + case 'installed': + case 'stopped': + case null: + default: + return lastError ? 'error' : 'stopped' + } +} + export interface OpenClawStatusResponse { status: OpenClawStatus podmanAvailable: boolean @@ -743,9 +765,15 @@ export class OpenClawService { // ── Status ─────────────────────────────────────────────────────────── async getStatus(): Promise { + // Runtime state is the source of truth for "is the container alive". + // Deriving the legacy status surface from it keeps the gateway block + // consistent with /runtimes/openclaw/status so the UI can't show two + // contradictory pills. + const runtimeState = this.runtime.getStatusSnapshot?.()?.state ?? null const isSetUp = existsSync(this.getStateConfigPath()) - if (!isSetUp) { - const machineStatus = await this.runtime.getMachineStatus() + const machineStatus = await this.runtime.getMachineStatus() + + if (!isSetUp || runtimeState === 'not_installed') { return { status: 'uninitialized', podmanAvailable: true, @@ -754,16 +782,15 @@ export class OpenClawService { agentCount: 0, error: null, controlPlaneStatus: 'disconnected', - lastGatewayError: this.lastGatewayError, - lastRecoveryReason: this.lastRecoveryReason, + lastGatewayError: null, + lastRecoveryReason: null, } } - const machineStatus = await this.runtime.getMachineStatus() - const ready = machineStatus.running ? await this.runtime.isReady() : false + const runtimeRunning = runtimeState === 'running' let agentCount = 0 - if (ready) { + if (runtimeRunning) { try { const agents = await this.runControlPlaneCall(() => this.cliClient.listAgents(), @@ -775,15 +802,17 @@ export class OpenClawService { } return { - status: ready ? 'running' : this.lastError ? 'error' : 'stopped', + status: mapRuntimeStateToLegacy(runtimeState, this.lastError), podmanAvailable: true, machineReady: machineStatus.running, port: this.hostPort, agentCount, error: this.lastError, - controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected', - lastGatewayError: this.lastGatewayError, - lastRecoveryReason: this.lastRecoveryReason, + controlPlaneStatus: runtimeRunning + ? this.controlPlaneStatus + : 'disconnected', + lastGatewayError: runtimeRunning ? this.lastGatewayError : null, + lastRecoveryReason: runtimeRunning ? this.lastRecoveryReason : null, } } diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index 1d6b91b16..eef65954a 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -250,6 +250,7 @@ describe('OpenClawService', () => { isPodmanAvailable: async () => true, getMachineStatus: async () => ({ initialized: true, running: true }), isReady: async () => true, + getStatusSnapshot: () => ({ state: 'running' as const }), } service.cliClient = { getConfig: mock(async () => 'cli-token'), @@ -282,6 +283,33 @@ describe('OpenClawService', () => { }) }) + it('reports uninitialized when runtime state is not_installed, ignoring stale legacy fields', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await mkdir(join(tempDir, '.openclaw'), { recursive: true }) + await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + // In-memory legacy fields left over from a previous lifecycle — + // these must not leak into the response when the runtime says the + // container is gone. + service.controlPlaneStatus = 'connected' + service.lastGatewayError = 'stale error' + service.runtime = { + isPodmanAvailable: async () => true, + getMachineStatus: async () => ({ initialized: false, running: false }), + isReady: async () => false, + getStatusSnapshot: () => ({ state: 'not_installed' as const }), + } + + const status = await service.getStatus() + + expect(status.status).toBe('uninitialized') + expect(status.controlPlaneStatus).toBe('disconnected') + expect(status.lastGatewayError).toBeNull() + expect(status.port).toBeNull() + }) + it('creates the main agent during setup when the gateway starts without one', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) const steps: string[] = [] From 73922445744f797675f6711803487d43b374e33b Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 16:47:20 +0530 Subject: [PATCH 10/15] refactor(openclaw): delete duplicated service-level lifecycle methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the start/stop/restart/reconnectControlPlane/shutdown surface on OpenClawService — these duplicated the new AgentRuntime state machine and were the root cause of the two views disagreeing. UI flows now go through runtime.executeAction via the RuntimeControlPanel; server shutdown via getOpenClawRuntime().executeAction({type:'stop'}). Server: - delete service.start/stop/restart/reconnectControlPlane/shutdown + stopGatewayLogTail (now unreferenced) - delete /claw/start /claw/stop /claw/restart /claw/reconnect routes - replace internal `await this.restart()` (createAgent, updateProviderKeys) with `runtime.restartGateway` — provider-config changes only need a container restart, not a control-plane re-probe - main.ts shutdown handler uses getOpenClawRuntime().executeAction directly UI: - useOpenClawMutations drops startOpenClaw/stopOpenClaw/restartOpenClaw/ reconnectOpenClaw and pendingGatewayAction; setup/create/delete remain - AgentsPage drops the legacy LifecycleAlert + ControlPlaneAlert blocks; the RuntimeControlPanel already renders pending state on its own action buttons Tests: - delete tests for the removed methods - runtime mocks in restart-side tests now expose restartGateway directly --- .../entrypoints/app/agents/AgentsPage.tsx | 98 ++-- .../entrypoints/app/agents/useOpenClaw.ts | 51 +- .../apps/server/src/api/routes/openclaw.ts | 48 -- .../api/services/openclaw/openclaw-service.ts | 154 +----- .../browseros-agent/apps/server/src/main.ts | 6 +- .../openclaw/openclaw-service.test.ts | 441 +----------------- 6 files changed, 50 insertions(+), 748 deletions(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx index a3891229e..98d9acd69 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -24,22 +24,14 @@ import { import { canManageOpenClawAgents, getAgentsLoading, - getControlPlaneCopyForStatus, getGatewayUiState, getInlineError, - getLifecycleBanner, - getRecoveryDetail, getVisibleOpenClawAgents, - shouldShowControlPlaneDegraded, toHarnessListItem, toOpenClawListItem, } from './agents-page-utils' import { NewAgentDialog } from './NewAgentDialog' -import { - ControlPlaneAlert, - InlineErrorAlert, - LifecycleAlert, -} from './OpenClawControls' +import { InlineErrorAlert } from './OpenClawControls' import { RuntimeControlPanel } from './runtime-controls/RuntimeControlPanel' import { RuntimeStatusBar } from './runtime-controls/RuntimeStatusBar' import { SetupOpenClawDialog } from './SetupOpenClawDialog' @@ -86,14 +78,9 @@ export const AgentsPage: FC = () => { setupOpenClaw, createAgent: createOpenClawAgent, deleteAgent: deleteOpenClawAgent, - restartOpenClaw, - reconnectOpenClaw, - actionInProgress, settingUp, creating: creatingOpenClawAgent, deleting: deletingOpenClawAgent, - reconnecting, - pendingGatewayAction, } = useOpenClawMutations() const [setupOpen, setSetupOpen] = useState(false) @@ -155,7 +142,10 @@ export const AgentsPage: FC = () => { setHarnessReasoningEffort, }) - const lifecyclePending = pendingGatewayAction !== null + // Lifecycle pending used to track legacy /claw/start /stop /restart /reconnect + // mutations. Those routes are gone — RuntimeControlPanel renders its own + // spinner on the action buttons. + const lifecyclePending = false const gatewayUiState = useMemo(() => getGatewayUiState(status), [status]) const openClawManageable = canManageOpenClawAgents( gatewayUiState, @@ -234,31 +224,30 @@ export const AgentsPage: FC = () => { setHarnessReasoningEffort(descriptor?.defaultReasoningEffort ?? '') } - const { handleCreate, handleDelete, handleSetup, runWithPageErrorHandling } = - createAgentPageActions({ - createProviderId, - createRuntime, - createHermesProviderId, - harnessModelId, - harnessReasoningEffort, - navigate, - newName, - selectableOpenClawProviders, - selectableHermesProviders, - setupProviderId, - createHarnessAgent: createHarnessAgent.mutateAsync, - createOpenClawAgent, - deleteHarnessAgent: deleteHarnessAgent.mutateAsync, - deleteOpenClawAgent, - setCliAuthModalOpen, - setCreateError, - setCreateOpen, - setDeletingAgentKey, - setNewName, - setPageError, - setSetupOpen, - setupOpenClaw, - }) + const { handleCreate, handleDelete, handleSetup } = createAgentPageActions({ + createProviderId, + createRuntime, + createHermesProviderId, + harnessModelId, + harnessReasoningEffort, + navigate, + newName, + selectableOpenClawProviders, + selectableHermesProviders, + setupProviderId, + createHarnessAgent: createHarnessAgent.mutateAsync, + createOpenClawAgent, + deleteHarnessAgent: deleteHarnessAgent.mutateAsync, + deleteOpenClawAgent, + setCliAuthModalOpen, + setCreateError, + setCreateOpen, + setDeletingAgentKey, + setNewName, + setPageError, + setSetupOpen, + setupOpenClaw, + }) if (showTerminal) { return setShowTerminal(false)} /> @@ -284,13 +273,9 @@ export const AgentsPage: FC = () => { ) } - const showControlPlaneDegraded = shouldShowControlPlaneDegraded( - gatewayUiState, - lifecyclePending, - ) - const lifecycleBanner = getLifecycleBanner(pendingGatewayAction) - const recoveryDetail = status ? getRecoveryDetail(status) : null - const controlPlaneCopy = getControlPlaneCopyForStatus(status) + // showControlPlaneDegraded gating was removed alongside ControlPlaneAlert + // — the new RuntimeControlPanel surfaces degraded state directly via the + // runtime status pill and CTAs. // Bar only makes sense when the gateway is meaningfully alive AND // there's at least one OpenClaw agent in the merged list. Hide it @@ -305,8 +290,6 @@ export const AgentsPage: FC = () => {
setCreateOpen(true)} /> - {lifecycleBanner ? : null} - {inlineError ? ( { /> ) : null} - {status && showControlPlaneDegraded ? ( - { - void runWithPageErrorHandling(reconnectOpenClaw) - }} - onRestart={() => { - void runWithPageErrorHandling(restartOpenClaw) - }} - /> - ) : null} - - clawFetch<{ status: string }>(ensureBaseUrl(), '/start', { - method: 'POST', - }), - onSuccess, - }) - - const stopMutation = useMutation({ - mutationFn: async () => - clawFetch<{ status: string }>(ensureBaseUrl(), '/stop', { - method: 'POST', - }), - onSuccess, - }) - - const restartMutation = useMutation({ - mutationFn: async () => - clawFetch<{ status: string }>(ensureBaseUrl(), '/restart', { - method: 'POST', - }), - onSuccess, - }) - - const reconnectMutation = useMutation({ - mutationFn: async () => - clawFetch<{ status: string }>(ensureBaseUrl(), '/reconnect', { - method: 'POST', - }), - onSuccess, - }) - - let pendingGatewayAction: GatewayLifecycleAction | null = null - if (setupMutation.isPending) pendingGatewayAction = 'setup' - else if (restartMutation.isPending) pendingGatewayAction = 'restart' - else if (stopMutation.isPending) pendingGatewayAction = 'stop' - else if (startMutation.isPending) pendingGatewayAction = 'start' - else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect' - return { setupOpenClaw: setupMutation.mutateAsync, createAgent: createMutation.mutateAsync, deleteAgent: deleteMutation.mutateAsync, - startOpenClaw: startMutation.mutateAsync, - stopOpenClaw: stopMutation.mutateAsync, - restartOpenClaw: restartMutation.mutateAsync, - reconnectOpenClaw: reconnectMutation.mutateAsync, actionInProgress: setupMutation.isPending || createMutation.isPending || - deleteMutation.isPending || - startMutation.isPending || - stopMutation.isPending || - restartMutation.isPending || - reconnectMutation.isPending, + deleteMutation.isPending, settingUp: setupMutation.isPending, creating: createMutation.isPending, deleting: deleteMutation.isPending, - reconnecting: reconnectMutation.isPending, - pendingGatewayAction, } } diff --git a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts index 00e4998dd..afb5b3283 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts @@ -111,54 +111,6 @@ export function createOpenClawRoutes() { } }) - .post('/start', async (c) => { - try { - logger.info('OpenClaw start requested') - await getOpenClawService().start() - return c.json({ status: 'running' }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.error('OpenClaw start failed', { error: message }) - return c.json({ error: message }, 500) - } - }) - - .post('/stop', async (c) => { - try { - logger.info('OpenClaw stop requested') - await getOpenClawService().stop() - return c.json({ status: 'stopped' }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.error('OpenClaw stop failed', { error: message }) - return c.json({ error: message }, 500) - } - }) - - .post('/restart', async (c) => { - try { - logger.info('OpenClaw restart requested') - await getOpenClawService().restart() - return c.json({ status: 'running' }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.error('OpenClaw restart failed', { error: message }) - return c.json({ error: message }, 500) - } - }) - - .post('/reconnect', async (c) => { - try { - logger.info('OpenClaw reconnect requested') - await getOpenClawService().reconnectControlPlane() - return c.json({ status: 'connected' }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.error('OpenClaw reconnect failed', { error: message }) - return c.json({ error: message }, 500) - } - }) - .get('/agents', async (c) => { try { const agents = await getOpenClawService().listAgents() diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index 910cfb1aa..cc965f8e9 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -119,9 +119,8 @@ function mapRuntimeStateToLegacy( return 'running' case 'errored': return 'error' - case 'installed': - case 'stopped': - case null: + // 'installed' / 'stopped' / null / unknown all map to stopped (or error + // when the service has a sticky lastError). default: return lastError ? 'error' : 'stopped' } @@ -627,141 +626,6 @@ export class OpenClawService { }) } - async start(onLog?: (msg: string) => void): Promise { - return this.withLifecycleLock('start', async () => { - const logProgress = this.createProgressLogger(onLog) - logger.info('Starting OpenClaw service', { - hostPort: this.hostPort, - }) - - await this.runtime.ensureReady(logProgress) - - await this.ensureStateEnvFile() - - await this.ensureGatewayPortAllocated(logProgress) - - if (await this.isCurrentGatewayAvailable(this.hostPort)) { - this.startGatewayLogTail() - this.controlPlaneStatus = 'connecting' - logProgress('Probing OpenClaw control plane...') - try { - await this.runControlPlaneCall(() => this.cliClient.probe()) - this.lastError = null - logger.info('OpenClaw gateway already running', { - hostPort: this.hostPort, - }) - return - } catch (error) { - logger.warn('OpenClaw control plane probe failed during start', { - hostPort: this.hostPort, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - logProgress('Starting OpenClaw gateway...') - await this.runtime.startGateway(undefined, logProgress) - this.startGatewayLogTail() - - logProgress('Waiting for gateway readiness...') - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - this.lastError = 'Gateway did not become ready after start' - throw new Error(this.lastError) - } - - this.controlPlaneStatus = 'connecting' - logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) - await this.ensureAllCliProvidersInstalled(logProgress) - this.lastError = null - logger.info('OpenClaw gateway started', { hostPort: this.hostPort }) - }) - } - - async stop(): Promise { - return this.withLifecycleLock('stop', async () => { - logger.info('Stopping OpenClaw service', { hostPort: this.hostPort }) - this.controlPlaneStatus = 'disconnected' - this.stopGatewayLogTail() - await this.runtime.stopGateway() - logger.info('OpenClaw container stopped') - }) - } - - async restart(onLog?: (msg: string) => void): Promise { - return this.withLifecycleLock('restart', async () => { - const logProgress = this.createProgressLogger(onLog) - logger.info('Restarting OpenClaw service', { - hostPort: this.hostPort, - }) - - this.controlPlaneStatus = 'reconnecting' - await this.runtime.ensureReady(logProgress) - this.stopGatewayLogTail() - await this.ensureStateEnvFile() - await this.ensureGatewayPortAllocated(logProgress) - logProgress('Restarting OpenClaw gateway...') - await this.runtime.restartGateway(undefined, logProgress) - this.startGatewayLogTail() - - logProgress('Waiting for gateway readiness...') - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - this.lastError = 'Gateway did not become ready after restart' - throw new Error(this.lastError) - } - - logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) - await this.ensureAllCliProvidersInstalled(logProgress) - this.lastError = null - logProgress('Gateway restarted successfully') - logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort }) - }) - } - - async reconnectControlPlane(onLog?: (msg: string) => void): Promise { - return this.withLifecycleLock('reconnect', async () => { - const logProgress = this.createProgressLogger(onLog) - logger.info('Reconnecting OpenClaw control plane', { - hostPort: this.hostPort, - }) - - logProgress('Checking gateway readiness...') - const ready = await this.runtime.isReady() - if (!ready) { - this.controlPlaneStatus = 'failed' - this.lastGatewayError = 'OpenClaw gateway is not ready' - this.lastRecoveryReason = 'container_not_ready' - throw new Error('OpenClaw gateway is not ready') - } - - this.controlPlaneStatus = 'reconnecting' - logProgress('Reconnecting control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) - logProgress('Control plane connected') - }) - } - - async shutdown(): Promise { - this.controlPlaneStatus = 'disconnected' - this.stopGatewayLogTail() - try { - await this.runtime.stopGateway() - } catch { - // Best effort during shutdown - } - await this.runtime.stopVm() - logger.info('OpenClaw shutdown complete') - } - // ── Status ─────────────────────────────────────────────────────────── async getStatus(): Promise { @@ -853,7 +717,7 @@ export class OpenClawService { configChanged, keysChanged, }) - await this.restart() + await this.runtime.restartGateway(undefined) } const model = provider.model @@ -1117,7 +981,7 @@ export class OpenClawService { const envChanged = await this.writeStateEnv(provider.envValues) const restarted = configChanged || envChanged if (restarted) { - await this.restart() + await this.runtime.restartGateway(undefined) } if (provider.model) { const model = provider.model @@ -1463,16 +1327,6 @@ export class OpenClawService { } } - private stopGatewayLogTail(): void { - if (!this.stopLogTail) return - try { - this.stopLogTail() - } catch { - // best effort - } - this.stopLogTail = null - } - private getHostWorkspaceDir(agentName: string): string { return getHostWorkspaceDir(this.openclawDir, agentName) } diff --git a/packages/browseros-agent/apps/server/src/main.ts b/packages/browseros-agent/apps/server/src/main.ts index 52e1a49dc..0a594ea8b 100644 --- a/packages/browseros-agent/apps/server/src/main.ts +++ b/packages/browseros-agent/apps/server/src/main.ts @@ -15,7 +15,6 @@ import { createHttpServer } from './api/server' import { configureOpenClawService, configureVmRuntime, - getOpenClawService, } from './api/services/openclaw/openclaw-service' import { CdpBackend } from './browser/backends/cdp' import { Browser } from './browser/browser' @@ -27,6 +26,7 @@ import { configureHermesRuntime, configureOpenClawRuntime, getHermesRuntime, + getOpenClawRuntime, } from './lib/agents/runtime' import { cleanOldSessions, @@ -193,8 +193,8 @@ export class Application { stop(reason?: string): void { logger.info('Shutting down server...', { reason }) stopSkillSync() - getOpenClawService() - .shutdown() + getOpenClawRuntime() + ?.executeAction({ type: 'stop' }) .catch(() => {}) getHermesRuntime() ?.executeAction({ type: 'stop' }) diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index eef65954a..ef0121b24 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -84,7 +84,7 @@ describe('OpenClawService', () => { } }) - function getSyntheticOccupiedPort(): number { + function _getSyntheticOccupiedPort(): number { const forced = Number.parseInt( process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT ?? '41003', 10, @@ -594,391 +594,6 @@ describe('OpenClawService', () => { }) }) - it('start uses the direct runtime startGateway flow', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - const ensureReady = mock(async () => {}) - const startGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - isReady: async () => false, - startGateway, - waitForReady, - } - service.cliClient = { - probe, - } - - await service.start() - - expect(ensureReady).toHaveBeenCalledTimes(1) - expect(startGateway).toHaveBeenCalledTimes(1) - expect(waitForReady).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledTimes(1) - }) - - it('serializes concurrent start calls and only starts the gateway once', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - let gatewayReady = false - let releaseStartGateway!: () => void - let notifyStartGatewayEntered!: () => void - const startGatewayEntered = new Promise((resolve) => { - notifyStartGatewayEntered = resolve - }) - const unblockStartGateway = new Promise((resolve) => { - releaseStartGateway = resolve - }) - const ensureReady = mock(async () => {}) - const startGateway = mock(async () => { - notifyStartGatewayEntered() - await unblockStartGateway - gatewayReady = true - }) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - isReady: async () => gatewayReady, - isGatewayCurrent: mock(async () => true), - startGateway, - waitForReady, - } - service.cliClient = { - probe, - } - mockGatewayAuth() - - const firstStart = service.start() - await startGatewayEntered - const secondStart = service.start() - releaseStartGateway() - await Promise.all([firstStart, secondStart]) - - expect(ensureReady).toHaveBeenCalledTimes(2) - expect(startGateway).toHaveBeenCalledTimes(1) - expect(waitForReady).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledTimes(2) - }) - - it('serializes start across service instances sharing an OpenClaw dir', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - let gatewayReady = false - let releaseStartGateway!: () => void - let notifyStartGatewayEntered!: () => void - const startGatewayEntered = new Promise((resolve) => { - notifyStartGatewayEntered = resolve - }) - const unblockStartGateway = new Promise((resolve) => { - releaseStartGateway = resolve - }) - const firstEnsureReady = mock(async () => {}) - const secondEnsureReady = mock(async () => {}) - const startGateway = mock(async () => { - notifyStartGatewayEntered() - await unblockStartGateway - gatewayReady = true - }) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const firstService = new OpenClawService() as MutableOpenClawService - const secondService = new OpenClawService() as MutableOpenClawService - - firstService.openclawDir = tempDir - secondService.openclawDir = tempDir - firstService.runtime = { - ensureReady: firstEnsureReady, - isReady: async () => gatewayReady, - isGatewayCurrent: async () => true, - startGateway, - waitForReady, - } - secondService.runtime = { - ensureReady: secondEnsureReady, - isReady: async () => gatewayReady, - isGatewayCurrent: async () => true, - startGateway, - waitForReady, - } - firstService.cliClient = { probe } - secondService.cliClient = { probe } - mockGatewayAuth() - - const firstStart = firstService.start() - await startGatewayEntered - const secondStart = secondService.start() - await Bun.sleep(25) - const secondEnteredBeforeFirstFinished = secondEnsureReady.mock.calls.length - - releaseStartGateway() - await Promise.all([firstStart, secondStart]) - - expect(secondEnteredBeforeFirstFinished).toBe(0) - expect(firstEnsureReady).toHaveBeenCalledTimes(1) - expect(secondEnsureReady).toHaveBeenCalledTimes(1) - expect(startGateway).toHaveBeenCalledTimes(1) - expect(waitForReady).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledTimes(2) - }) - - it('does not restart a ready gateway when start is called again', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - const ensureReady = mock(async () => {}) - const startGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - isReady: async () => true, - isGatewayCurrent: mock(async () => true), - startGateway, - waitForReady, - } - service.cliClient = { - probe, - } - mockGatewayAuth() - - await service.start() - - expect(ensureReady).toHaveBeenCalledTimes(1) - expect(startGateway).not.toHaveBeenCalled() - expect(waitForReady).not.toHaveBeenCalled() - expect(probe).toHaveBeenCalledTimes(1) - }) - - it('restart uses the direct runtime restartGateway flow', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - const ensureReady = mock(async () => {}) - const restartGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - isReady: async () => true, - restartGateway, - waitForReady, - } - service.cliClient = { - probe, - } - mockGatewayAuth() - - await service.restart() - - expect(ensureReady).toHaveBeenCalledTimes(1) - expect(restartGateway).toHaveBeenCalledTimes(1) - expect(waitForReady).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledTimes(1) - }) - - it('restart keeps the persisted gateway port when the current gateway already owns it', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - const occupiedPort = getSyntheticOccupiedPort() - await writeFile( - join(tempDir, '.openclaw', 'runtime-state.json'), - `${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`, - ) - const ensureReady = mock(async () => {}) - const restartGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - // Persisted port is reachable on /readyz; auth pass keeps it. - isReady: async () => true, - restartGateway, - waitForReady, - } - service.cliClient = { - probe, - } - mockGatewayAuth() - - await service.restart() - - expect(restartGateway).toHaveBeenCalledTimes(1) - expect(service.getPort()).toBe(occupiedPort) - expect(ensureReady).toHaveBeenCalledTimes(1) - }) - - it('restart moves off a persisted ready port when auth rejects the current token', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - const occupiedPort = getSyntheticOccupiedPort() - await writeFile( - join(tempDir, '.openclaw', 'runtime-state.json'), - `${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`, - ) - const ensureReady = mock(async () => {}) - const restartGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - // Persisted port is reachable on the readiness probe; auth - // rejection drives the move-off branch. - isReady: async () => true, - restartGateway, - waitForReady, - } - service.cliClient = { - probe, - } - mockGatewayAuth(401) - - await service.restart() - - expect(restartGateway).toHaveBeenCalledTimes(1) - expect(service.getPort()).not.toBe(occupiedPort) - expect(ensureReady).toHaveBeenCalledTimes(1) - }) - - it('stops the stale gateway before re-allocating port on auth mismatch', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }), - ) - const occupiedPort = getSyntheticOccupiedPort() - await writeFile( - join(tempDir, '.openclaw', 'runtime-state.json'), - `${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`, - ) - const ensureReady = mock(async () => {}) - const restartGateway = mock(async () => {}) - const stopGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - isReady: async () => true, - restartGateway, - stopGateway, - waitForReady, - } - service.cliClient = { probe } - mockGatewayAuth(401) - - await service.restart() - - expect(stopGateway).toHaveBeenCalledTimes(1) - expect(restartGateway).toHaveBeenCalledTimes(1) - expect(service.getPort()).not.toBe(occupiedPort) - }) - - it('stop calls runtime.stopGateway', async () => { - const stopGateway = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.runtime = { - isReady: async () => true, - stopGateway, - } - - await service.stop() - - expect(stopGateway).toHaveBeenCalledTimes(1) - }) - it('getLogs proxies to runtime.getGatewayLogs with tail', async () => { const getGatewayLogs = mock(async (tail = 50) => [`tail:${tail}`]) const service = new OpenClawService() as MutableOpenClawService @@ -992,42 +607,6 @@ describe('OpenClawService', () => { expect(getGatewayLogs).toHaveBeenCalledWith(25) }) - it('shutdown stops gateway and then stops the VM', async () => { - const stopGateway = mock(async () => {}) - const stopVm = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.runtime = { - isReady: async () => true, - stopGateway, - stopVm, - } - - await service.shutdown() - - expect(stopGateway).toHaveBeenCalledTimes(1) - expect(stopVm).toHaveBeenCalledTimes(1) - }) - - it('shutdown still stops the VM when stopGateway fails', async () => { - const stopGateway = mock(async () => { - throw new Error('stop failed') - }) - const stopVm = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.runtime = { - isReady: async () => true, - stopGateway, - stopVm, - } - - await expect(service.shutdown()).resolves.toBeUndefined() - - expect(stopGateway).toHaveBeenCalledTimes(1) - expect(stopVm).toHaveBeenCalledTimes(1) - }) - it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) await mkdir(join(tempDir, '.openclaw'), { recursive: true }) @@ -1313,8 +892,8 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1353,8 +932,8 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1401,8 +980,8 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1501,8 +1080,8 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1549,7 +1128,6 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart await expect( service.updateProviderKeys({ @@ -1583,8 +1161,8 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + restartGateway: restart, isReady: async () => true, waitForReady: mock(async () => true), } @@ -1642,7 +1220,6 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart await service.updateProviderKeys({ providerType: 'openai', @@ -1669,8 +1246,8 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + restartGateway: restart, isReady: async () => true, waitForReady: async () => true, } @@ -1707,7 +1284,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart + service.runtime = { + restartGateway: restart, + } service.cliClient = { setDefaultModel, } From 4806eb414d71f6b4e9ba83263b1836dc62bce0d2 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 16:59:19 +0530 Subject: [PATCH 11/15] refactor(openclaw): drop /claw/status, getStatus, and the gateway block --- .../app/agent-command/AgentCommandHome.tsx | 11 +- .../agent-command/agent-command-layout.tsx | 32 ++-- .../entrypoints/app/agents/AgentsPage.tsx | 109 +++---------- .../agent/entrypoints/app/agents/useAgents.ts | 13 +- .../entrypoints/app/agents/useOpenClaw.ts | 37 +---- .../apps/server/src/api/routes/agents.ts | 15 +- .../apps/server/src/api/routes/openclaw.ts | 5 - .../apps/server/src/api/server.ts | 1 - .../services/agents/agent-harness-service.ts | 28 ---- .../api/services/openclaw/openclaw-service.ts | 153 +----------------- .../openclaw/openclaw-service.test.ts | 71 -------- 11 files changed, 55 insertions(+), 420 deletions(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx index 8775d1e93..1bd8d684e 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/AgentCommandHome.tsx @@ -97,7 +97,7 @@ export const AgentCommandHome: FC = () => { // from the layout context (handles legacy /claw/agents entries that // haven't yet been backfilled into the harness store). The Recent // Agents grid below reads the richer harness payload directly. - const { agents: legacyAgents, status } = useAgentCommandData() + const { agents: legacyAgents, openClawReady } = useAgentCommandData() const { harnessAgents } = useHarnessAgents() const { adapters } = useAgentAdapters() const [selectedAgentId, setSelectedAgentId] = useState(null) @@ -146,10 +146,13 @@ export const AgentCommandHome: FC = () => { (agent) => agent.agentId === selectedAgentId, ) const selectedAgentReady = selectedAgent - ? selectedAgent.source === 'agent-harness' || status?.status === 'running' + ? selectedAgent.source === 'agent-harness' || openClawReady : false - const selectedAgentStatus = - selectedAgent?.source === 'agent-harness' ? 'running' : status?.status + const selectedAgentStatus = selectedAgent + ? selectedAgent.source === 'agent-harness' || openClawReady + ? 'running' + : 'stopped' + : undefined const selectedAgentName = selectedAgent?.name ?? orderedAgents[0]?.name ?? 'your agent' diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/agent-command-layout.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/agent-command-layout.tsx index 89e913dc4..18429b01c 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/agent-command-layout.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agent-command/agent-command-layout.tsx @@ -1,31 +1,25 @@ import type { FC } from 'react' import { Outlet, useOutletContext } from 'react-router' import { useHarnessAgents } from '@/entrypoints/app/agents/useAgents' -import type { - AgentEntry, - OpenClawStatus, -} from '@/entrypoints/app/agents/useOpenClaw' -import { - useOpenClawAgents, - useOpenClawStatus, -} from '@/entrypoints/app/agents/useOpenClaw' +import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw' +import { useOpenClawAgents } from '@/entrypoints/app/agents/useOpenClaw' +import { useRuntime } from '@/entrypoints/app/agents/useRuntime' interface AgentCommandContextValue { agents: AgentEntry[] agentsLoading: boolean - status: OpenClawStatus | null - statusLoading: boolean + openClawReady: boolean + openClawReadyLoading: boolean } export const AgentCommandLayout: FC = () => { - const { status, loading: statusLoading } = useOpenClawStatus(5000) - const openClawEnabled = - status?.status === 'running' && status.controlPlaneStatus === 'connected' + const { data: runtime, isLoading: runtimeLoading } = useRuntime('openclaw') + const openClawReady = runtime?.status.state === 'running' const { agents: openClawAgents, loading: openClawAgentsLoading } = - useOpenClawAgents(openClawEnabled) + useOpenClawAgents(openClawReady) const { agents: harnessAgents, loading: harnessAgentsLoading } = useHarnessAgents() - const visibleOpenClawAgents = openClawEnabled ? openClawAgents : [] + const visibleOpenClawAgents = openClawReady ? openClawAgents : [] // Dual-created OpenClaw agents appear in both `/claw/agents` (gateway // record) and `/agents` (harness record) under the same id. Prefer the // harness entry so the chat panel can route through the harness path @@ -43,10 +37,10 @@ export const AgentCommandLayout: FC = () => { agents, agentsLoading: harnessAgentsLoading || - statusLoading || - (openClawEnabled && openClawAgentsLoading), - status, - statusLoading, + runtimeLoading || + (openClawReady && openClawAgentsLoading), + openClawReady, + openClawReadyLoading: runtimeLoading, } satisfies AgentCommandContextValue } /> diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx index 98d9acd69..27861255f 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -1,10 +1,8 @@ import { Loader2, Terminal as TerminalIcon } from 'lucide-react' import { type FC, useMemo, useState } from 'react' import { useNavigate } from 'react-router' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' -import { cn } from '@/lib/utils' import { AgentList } from './AgentList' import { AgentsHeader } from './AgentsHeader' import { AgentTerminal } from './AgentTerminal' @@ -22,9 +20,7 @@ import { DEFAULT_HARNESS_ADAPTER, } from './agents-page-types' import { - canManageOpenClawAgents, getAgentsLoading, - getGatewayUiState, getInlineError, getVisibleOpenClawAgents, toHarnessListItem, @@ -43,6 +39,7 @@ import { useUpdateHarnessAgent, } from './useAgents' import { useOpenClawAgents, useOpenClawMutations } from './useOpenClaw' +import { useRuntime } from './useRuntime' export const AgentsPage: FC = () => { const navigate = useNavigate() @@ -53,19 +50,15 @@ export const AgentsPage: FC = () => { error: adaptersError, } = useAgentAdapters() - // The harness listing now carries the gateway lifecycle snapshot - // alongside the agents — one polling source for everything the - // agents page renders. The legacy `/claw/status` poll is dead from - // this surface; the chat-panel layout still uses it for now. const { harnessAgents, - gateway: status, loading: harnessAgentsLoading, error: harnessAgentsError, } = useHarnessAgents() + const { data: openClawRuntime } = useRuntime('openclaw') + const openClawRunning = openClawRuntime?.status.state === 'running' - const openClawAgentsEnabled = - status?.status === 'running' && status.controlPlaneStatus === 'connected' + const openClawAgentsEnabled = openClawRunning const { agents: openClawAgents, loading: openClawAgentsLoading, @@ -142,15 +135,10 @@ export const AgentsPage: FC = () => { setHarnessReasoningEffort, }) - // Lifecycle pending used to track legacy /claw/start /stop /restart /reconnect - // mutations. Those routes are gone — RuntimeControlPanel renders its own - // spinner on the action buttons. - const lifecyclePending = false - const gatewayUiState = useMemo(() => getGatewayUiState(status), [status]) - const openClawManageable = canManageOpenClawAgents( - gatewayUiState, - lifecyclePending, - ) + // Can the user create / modify OpenClaw agents? Yes when the runtime + // is running. The legacy gatewayUiState/controlPlaneStatus gating is + // gone — runtime state is the source of truth. + const openClawManageable = openClawRunning const visibleOpenClawAgents = getVisibleOpenClawAgents( openClawAgentsEnabled, openClawAgents, @@ -203,7 +191,7 @@ export const AgentsPage: FC = () => { return map }, [harnessAgents]) const inlineError = getInlineError({ - lifecyclePending, + lifecyclePending: false, pageError, openClawAgentsError, adaptersError, @@ -265,7 +253,7 @@ export const AgentsPage: FC = () => { // First-paint loader: until the harness listing has resolved at // least once we don't know which adapters / agents to render. - if (harnessAgentsLoading && !status) { + if (harnessAgentsLoading && !openClawRuntime) { return (
@@ -273,17 +261,19 @@ export const AgentsPage: FC = () => { ) } - // showControlPlaneDegraded gating was removed alongside ControlPlaneAlert - // — the new RuntimeControlPanel surfaces degraded state directly via the - // runtime status pill and CTAs. - - // Bar only makes sense when the gateway is meaningfully alive AND - // there's at least one OpenClaw agent in the merged list. Hide it - // for Claude/Codex-only setups so the page stays uncluttered. + // Bar only makes sense when the gateway is running AND there's at + // least one OpenClaw agent in the merged list. Hide it for + // Claude/Codex-only setups so the page stays uncluttered. const showGatewayStatusBar = - status?.status === 'running' && + openClawRunning && (visibleOpenClawAgents.length > 0 || harnessAgents.some((agent) => agent.adapter === 'openclaw')) + // Setup CTA appears when the runtime is healthy but the user has not + // yet configured a provider (no openclaw.json on disk → runtime is + // running but agent CRUD will fail). For now: surface it whenever the + // runtime isn't ready, so a fresh user sees both Install + Configure + // affordances. A future server endpoint can tell us "is setup done". + const showSetupCta = !openClawRunning return (
@@ -300,7 +290,7 @@ export const AgentsPage: FC = () => { { {showGatewayStatusBar ? ( - ) : null - } extraActions={
) } - -const ControlPlanePill: FC<{ status: string }> = ({ status }) => { - const pill = pillForControlPlane(status) - return ( - - - {pill.label} - - ) -} - -interface ControlPlanePillKind { - variant: 'default' | 'secondary' | 'outline' | 'destructive' - label: string - dot: string - className?: string -} - -function pillForControlPlane(status: string): ControlPlanePillKind { - switch (status) { - case 'connected': - return { - variant: 'secondary', - label: 'Control plane connected', - dot: 'bg-emerald-500', - className: 'bg-emerald-50 text-emerald-900 hover:bg-emerald-50', - } - case 'connecting': - case 'reconnecting': - case 'recovering': - return { - variant: 'secondary', - label: 'Connecting', - dot: 'bg-amber-500 animate-pulse', - className: 'bg-amber-50 text-amber-900 hover:bg-amber-50', - } - case 'failed': - return { - variant: 'destructive', - label: 'Needs attention', - dot: 'bg-destructive-foreground', - } - default: - return { - variant: 'outline', - label: 'Disconnected', - dot: 'bg-muted-foreground/40', - } - } -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.ts index d4da5015c..4d3003381 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useAgents.ts @@ -11,16 +11,9 @@ import { type HarnessQueuedMessage, mapHarnessAgentToEntry, } from './agent-harness-types' -import type { OpenClawStatus } from './useOpenClaw' -/** - * Combined response shape of `GET /agents`. The page polls this once - * and consumes both fields, replacing the dedicated `/claw/status` - * poll the previous design carried. - */ interface HarnessAgentsResponse { agents: HarnessAgent[] - gateway: OpenClawStatus | null } export type { AgentHarnessStreamEvent } @@ -94,10 +87,7 @@ export function useHarnessAgents(enabled = true) { baseUrl as string, '/', ) - return { - agents: data.agents ?? [], - gateway: data.gateway ?? null, - } + return { agents: data.agents ?? [] } }, enabled: Boolean(baseUrl) && !urlLoading && enabled, // Poll every 5s so the per-agent liveness state (working / idle / @@ -111,7 +101,6 @@ export function useHarnessAgents(enabled = true) { return { agents: (query.data?.agents ?? []).map(mapHarnessAgentToEntry), harnessAgents: query.data?.agents ?? [], - gateway: query.data?.gateway ?? null, loading: query.isLoading || urlLoading, error: query.error ?? urlError, refetch: query.refetch, diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts index 96e1d2434..94cb585ec 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts @@ -9,6 +9,11 @@ export interface AgentEntry { source?: 'openclaw' | 'agent-harness' } +/** + * Vestige type kept so the legacy UI helpers in agents-page-utils + + * OpenClawControls + agents-page-types still compile. Those files are + * the next deletion target — once they're gone this can vanish too. + */ export interface OpenClawStatus { status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error' podmanAvailable: boolean @@ -62,7 +67,6 @@ export function getModelDisplayName(model: unknown): string | undefined { } export const OPENCLAW_QUERY_KEYS = { - status: 'openclaw-status', agents: 'openclaw-agents', } as const @@ -92,10 +96,6 @@ async function clawFetch( return res.json() as Promise } -async function fetchOpenClawStatus(baseUrl: string): Promise { - return clawFetch(baseUrl, '/status') -} - async function fetchOpenClawAgents(baseUrl: string): Promise { const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents') return (data.agents ?? []).map((agent) => ({ @@ -107,32 +107,9 @@ async function fetchOpenClawAgents(baseUrl: string): Promise { async function invalidateOpenClawQueries( queryClient: ReturnType, ): Promise { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.status] }), - queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.agents] }), - ]) -} - -export function useOpenClawStatus(pollMs = 5000) { - const { - baseUrl, - isLoading: urlLoading, - error: urlError, - } = useAgentServerUrl() - - const query = useQuery({ - queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl], - queryFn: () => fetchOpenClawStatus(baseUrl as string), - enabled: !!baseUrl && !urlLoading, - refetchInterval: pollMs, + await queryClient.invalidateQueries({ + queryKey: [OPENCLAW_QUERY_KEYS.agents], }) - - return { - status: query.data ?? null, - loading: query.isLoading || urlLoading, - error: query.error ?? urlError, - refetch: query.refetch, - } } export function useOpenClawAgents(enabled = true) { diff --git a/packages/browseros-agent/apps/server/src/api/routes/agents.ts b/packages/browseros-agent/apps/server/src/api/routes/agents.ts index 12b639f66..4e740c78f 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/agents.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/agents.ts @@ -33,7 +33,6 @@ import type { AgentHistoryPage, AgentStreamEvent } from '../../lib/agents/types' import { type AgentDefinitionWithActivity, AgentHarnessService, - type GatewayStatusSnapshot, HermesProviderConfigInvalidError, InvalidAgentUpdateError, MessageQueueFullError, @@ -52,7 +51,6 @@ import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-p type AgentRouteService = { listAgents(): Promise listAgentsWithActivity(): Promise - getGatewayStatus(): Promise createAgent(input: { name: string adapter: AgentAdapter @@ -173,15 +171,10 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) { return c.json({ adapters }) }) .get('/', async (c) => { - // Single round-trip the agents page consumes: enriched agents - // (status + lastUsedAt) plus the gateway lifecycle snapshot the - // GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used - // to fetch from `/claw/status`. Lets the page poll one endpoint. - const [agents, gateway] = await Promise.all([ - service.listAgentsWithActivity(), - service.getGatewayStatus(), - ]) - return c.json({ agents, gateway }) + // Enriched agents (status + lastUsedAt) in a single round-trip; + // gateway lifecycle now reads from /runtimes/openclaw/status. + const agents = await service.listAgentsWithActivity() + return c.json({ agents }) }) .post('/', async (c) => { const parsed = await parseCreateAgentBody(c) diff --git a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts index afb5b3283..16c4fd2b2 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts @@ -30,11 +30,6 @@ function getCreateAgentValidationError(body: { name?: string }): string | null { export function createOpenClawRoutes() { return new Hono() - .get('/status', async (c) => { - const status = await getOpenClawService().getStatus() - return c.json(status) - }) - .get('/providers/:providerId/auth-status', async (c) => { const { providerId } = c.req.param() const provider = getOpenClawCliProvider(providerId) diff --git a/packages/browseros-agent/apps/server/src/api/server.ts b/packages/browseros-agent/apps/server/src/api/server.ts index 8e8fcd787..942faa2a8 100644 --- a/packages/browseros-agent/apps/server/src/api/server.ts +++ b/packages/browseros-agent/apps/server/src/api/server.ts @@ -152,7 +152,6 @@ export async function createHttpServer(config: HttpServerConfig) { model: agent.model, })) }, - getStatus: () => getOpenClawService().getStatus(), getAgentHistory: async (agentId) => { // Aggregated across the agent's main + every sub-session // (cron / hook / channel) so autonomous turns surface in diff --git a/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts b/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts index 0193bc55e..21297fbd8 100644 --- a/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts @@ -122,15 +122,6 @@ export interface OpenClawProvisioner { listAgents(): Promise< Array<{ agentId: string; name: string; model?: string }> > - /** - * Optional. When wired, the harness exposes the gateway lifecycle - * snapshot through `GET /agents` so the agents page can render - * Running / Control plane connected pills without a separate - * `/claw/status` poll. Returns the same shape as the legacy - * endpoint; `null` when the snapshot can't be fetched (e.g. the - * gateway is not configured at all). - */ - getStatus?(): Promise /** * Optional. When wired, the harness uses this for `getHistory` on * openclaw-adapter agents so the chat panel sees autonomous @@ -311,25 +302,6 @@ export class AgentHarnessService { }) } - /** - * Read the gateway lifecycle snapshot through the wired provisioner. - * Returns null if no provisioner is configured or it doesn't expose - * `getStatus`; route-layer callers should treat that as "no gateway, - * skip rendering OpenClaw-only chrome." Errors get logged + swallowed - * so a transient gateway issue doesn't 500 the listing endpoint. - */ - async getGatewayStatus(): Promise { - if (!this.openclawProvisioner?.getStatus) return null - try { - return await this.openclawProvisioner.getStatus() - } catch (err) { - logger.warn('Failed to fetch gateway status for /agents listing', { - error: err instanceof Error ? err.message : String(err), - }) - return null - } - } - /** * Pull one snapshot per agent in parallel. Falls back to a * lastUsedAt-only snapshot when the runtime doesn't implement diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index cc965f8e9..8c03bcc15 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -80,64 +80,6 @@ const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/ const OPENCLAW_BROWSEROS_USER_SESSION_PATTERN = /^agent:[^:]+:openai-user:browseros:[^:]+:(.+)$/ -export type OpenClawControlPlaneStatus = - | 'disconnected' - | 'connecting' - | 'connected' - | 'reconnecting' - // Retained for extension compatibility while the UI still branches on it. - | 'recovering' - | 'failed' - -export type OpenClawGatewayRecoveryReason = - // Retained for extension compatibility while the UI still renders these reasons. - | 'transient_disconnect' - | 'signature_expired' - | 'pairing_required' - | 'token_mismatch' - | 'container_not_ready' - | 'unknown' - -export type OpenClawStatus = - | 'uninitialized' - | 'starting' - | 'running' - | 'stopped' - | 'error' - -function mapRuntimeStateToLegacy( - state: string | null, - lastError: string | null, -): OpenClawStatus { - switch (state) { - case 'not_installed': - return 'uninitialized' - case 'installing': - case 'starting': - return 'starting' - case 'running': - return 'running' - case 'errored': - return 'error' - // 'installed' / 'stopped' / null / unknown all map to stopped (or error - // when the service has a sticky lastError). - default: - return lastError ? 'error' : 'stopped' - } -} - -export interface OpenClawStatusResponse { - status: OpenClawStatus - podmanAvailable: boolean - machineReady: boolean - port: number | null - agentCount: number - error: string | null - controlPlaneStatus: OpenClawControlPlaneStatus - lastGatewayError: string | null - lastRecoveryReason: OpenClawGatewayRecoveryReason | null -} - export type OpenClawAgentEntry = OpenClawAgentRecord export interface SetupInput { @@ -389,9 +331,6 @@ export class OpenClawService { private browserosServerPort: number private resourcesDir: string | null private browserosDir: string | undefined - private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected' - private lastGatewayError: string | null = null - private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null private stopLogTail: (() => void) | null = null private lifecycleLock: Promise = Promise.resolve() private clawSession = new ClawSession() @@ -595,7 +534,6 @@ export class OpenClawService { throw new Error(this.lastError) } - this.controlPlaneStatus = 'connecting' logProgress('Probing OpenClaw control plane...') await this.runControlPlaneCall(() => this.cliClient.probe()) @@ -626,60 +564,6 @@ export class OpenClawService { }) } - // ── Status ─────────────────────────────────────────────────────────── - - async getStatus(): Promise { - // Runtime state is the source of truth for "is the container alive". - // Deriving the legacy status surface from it keeps the gateway block - // consistent with /runtimes/openclaw/status so the UI can't show two - // contradictory pills. - const runtimeState = this.runtime.getStatusSnapshot?.()?.state ?? null - const isSetUp = existsSync(this.getStateConfigPath()) - const machineStatus = await this.runtime.getMachineStatus() - - if (!isSetUp || runtimeState === 'not_installed') { - return { - status: 'uninitialized', - podmanAvailable: true, - machineReady: machineStatus.running, - port: null, - agentCount: 0, - error: null, - controlPlaneStatus: 'disconnected', - lastGatewayError: null, - lastRecoveryReason: null, - } - } - - const runtimeRunning = runtimeState === 'running' - - let agentCount = 0 - if (runtimeRunning) { - try { - const agents = await this.runControlPlaneCall(() => - this.cliClient.listAgents(), - ) - agentCount = agents.length - } catch { - // latest control plane error is captured by runControlPlaneCall - } - } - - return { - status: mapRuntimeStateToLegacy(runtimeState, this.lastError), - podmanAvailable: true, - machineReady: machineStatus.running, - port: this.hostPort, - agentCount, - error: this.lastError, - controlPlaneStatus: runtimeRunning - ? this.controlPlaneStatus - : 'disconnected', - lastGatewayError: runtimeRunning ? this.lastGatewayError : null, - lastRecoveryReason: runtimeRunning ? this.lastRecoveryReason : null, - } - } - // ── Agent Management (via CLI) ────────────────────────────────────── async createAgent(input: { @@ -1271,45 +1155,12 @@ export class OpenClawService { } private async assertGatewayReady(): Promise { - const portReady = await this.runtime.isReady() - logger.debug('Checking OpenClaw gateway readiness before use', { - hostPort: this.hostPort, - portReady, - controlPlaneStatus: this.controlPlaneStatus, - }) - if (portReady) { - return - } - - this.controlPlaneStatus = 'failed' - this.lastGatewayError = 'OpenClaw gateway is not ready' - this.lastRecoveryReason = 'container_not_ready' + if (await this.runtime.isReady()) return throw new Error('OpenClaw gateway is not ready') } private async runControlPlaneCall(fn: () => Promise): Promise { - try { - const result = await fn() - this.controlPlaneStatus = 'connected' - this.lastGatewayError = null - this.lastRecoveryReason = null - return result - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const reason = this.classifyControlPlaneError(error) - this.controlPlaneStatus = 'failed' - this.lastGatewayError = message - this.lastRecoveryReason = reason - throw error - } - } - - private classifyControlPlaneError( - error: unknown, - ): OpenClawGatewayRecoveryReason { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('not ready')) return 'container_not_ready' - return 'unknown' + return fn() } private startGatewayLogTail(): void { diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index ef0121b24..62e0ebe43 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -239,77 +239,6 @@ describe('OpenClawService', () => { ).toBe('e1ee8e17-4fdb-4072-99ce-8f680853ec00') }) - it('maps successful cli client probes into connected status', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - isPodmanAvailable: async () => true, - getMachineStatus: async () => ({ initialized: true, running: true }), - isReady: async () => true, - getStatusSnapshot: () => ({ state: 'running' as const }), - } - service.cliClient = { - getConfig: mock(async () => 'cli-token'), - listAgents: mock(async () => [ - { - agentId: 'main', - name: 'main', - workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`, - }, - { - agentId: 'ops', - name: 'ops', - workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`, - }, - ]), - } - - const status = await service.getStatus() - - expect(status).toEqual({ - status: 'running', - podmanAvailable: true, - machineReady: true, - port: 18789, - agentCount: 2, - error: null, - controlPlaneStatus: 'connected', - lastGatewayError: null, - lastRecoveryReason: null, - }) - }) - - it('reports uninitialized when runtime state is not_installed, ignoring stale legacy fields', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - // In-memory legacy fields left over from a previous lifecycle — - // these must not leak into the response when the runtime says the - // container is gone. - service.controlPlaneStatus = 'connected' - service.lastGatewayError = 'stale error' - service.runtime = { - isPodmanAvailable: async () => true, - getMachineStatus: async () => ({ initialized: false, running: false }), - isReady: async () => false, - getStatusSnapshot: () => ({ state: 'not_installed' as const }), - } - - const status = await service.getStatus() - - expect(status.status).toBe('uninitialized') - expect(status.controlPlaneStatus).toBe('disconnected') - expect(status.lastGatewayError).toBeNull() - expect(status.port).toBeNull() - }) - it('creates the main agent during setup when the gateway starts without one', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) const steps: string[] = [] From fdc6b8039510abed3ce822faa4b4bad454c19ba9 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 17:37:20 +0530 Subject: [PATCH 12/15] refactor(openclaw): move gateway port ownership into the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port persistence + reconciliation now lives entirely on the runtime side. Service keeps a lazy httpClient getter that always reads the current port from runtime.getHostPort(), so a port change (via syncState drift detection) propagates everywhere automatically. Server: - OpenClawContainerRuntime seeds hostPort from runtime-state.json at construction (readPersistedGatewayPortSync) and writes back via syncState when the live container's mapping drifts - OpenClawService.hostPort, setPort, adoptRuntimeHostPort, ensureGatewayPortAllocated, isCurrentGatewayAvailable, isGatewayAvailable, isGatewayAuthenticated, isGatewayPortReady, the httpClient field, and the local fetchOk all deleted - tryAutoStart is now ~10 lines: syncState → executeAction({type:start}) → control-plane probe; no port juggling, no auth-mismatch realloc (that path was driving the broken-state bug from earlier) - internal `this.hostPort` references now go through runtime.getHostPort() Tests: - delete the four obsolete tryAutoStart tests (each asserted internals that are gone) plus the unused mockGatewayAuth helpers - add two slim tryAutoStart tests pinning the new contract - existing runtime tests still call setHostPort, so the method survives as a test-only override --- .../api/services/openclaw/openclaw-service.ts | 198 ++---------------- .../api/services/openclaw/runtime-state.ts | 39 +++- .../runtime/openclaw-container-runtime.ts | 35 +++- .../openclaw/openclaw-service.test.ts | 169 +++------------ 4 files changed, 115 insertions(+), 326 deletions(-) diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index 8c03bcc15..4e57b1c9c 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -69,11 +69,6 @@ import { type ResolvedOpenClawProviderConfig, resolveSupportedOpenClawProvider, } from './openclaw-provider-map' -import { - allocateGatewayPort, - readPersistedGatewayPort, - writePersistedGatewayPort, -} from './runtime-state' const READY_TIMEOUT_MS = 30_000 const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/ @@ -324,9 +319,7 @@ export class OpenClawService { private runtime: OpenClawContainerRuntime private cliClient: OpenClawCliClient private bootstrapCliClient: OpenClawCliClient - private httpClient: OpenClawHttpClient private openclawDir: string - private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT private lastError: string | null = null private browserosServerPort: number private resourcesDir: string | null @@ -341,16 +334,20 @@ export class OpenClawService { resourcesDir: config.resourcesDir, browserosDir: config.browserosDir, }) - this.runtime.setHostPort(this.hostPort) this.cliClient = new OpenClawCliClient(this.runtime) this.bootstrapCliClient = this.buildBootstrapCliClient() - this.httpClient = new OpenClawHttpClient(this.hostPort) this.browserosServerPort = config.browserosServerPort ?? DEFAULT_PORTS.server this.resourcesDir = config.resourcesDir ?? null this.browserosDir = config.browserosDir } + /** Lazy HTTP client — port can drift via runtime.syncState, so we + * never cache the URL. Cheap to construct (just a port-bound object). */ + private get httpClient(): OpenClawHttpClient { + return new OpenClawHttpClient(this.runtime.getHostPort()) + } + configure(config: OpenClawServiceConfig): void { if (config.browserosServerPort !== undefined) { this.browserosServerPort = config.browserosServerPort @@ -371,7 +368,7 @@ export class OpenClawService { } getPort(): number { - return this.hostPort + return this.runtime.getHostPort() } /** Subscribe to real-time agent status changes from the ClawSession state machine. */ @@ -474,7 +471,7 @@ export class OpenClawService { const logProgress = this.createProgressLogger(onLog) const provider = this.resolveProviderForAgent(input) logger.info('Starting OpenClaw setup', { - hostPort: this.hostPort, + hostPort: this.runtime.getHostPort(), browserosServerPort: this.browserosServerPort, providerType: input.providerType, providerName: input.providerName, @@ -496,8 +493,6 @@ export class OpenClawService { providerKeyCount: Object.keys(provider.envValues).length, }) - await this.ensureGatewayPortAllocated(logProgress) - logProgress('Bootstrapping OpenClaw config...') await this.bootstrapCliClient.runOnboard({ acceptRisk: true, @@ -524,7 +519,7 @@ export class OpenClawService { this.startGatewayLogTail() logProgress('Waiting for gateway readiness...') const ready = await this.runtime.waitForReady( - this.hostPort, + this.runtime.getHostPort(), READY_TIMEOUT_MS, ) if (!ready) { @@ -558,9 +553,11 @@ export class OpenClawService { this.lastError = null logProgress( - `OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`, + `OpenClaw gateway running at http://127.0.0.1:${this.runtime.getHostPort()}`, ) - logger.info('OpenClaw setup complete', { hostPort: this.hostPort }) + logger.info('OpenClaw setup complete', { + hostPort: this.runtime.getHostPort(), + }) }) } @@ -905,51 +902,21 @@ export class OpenClawService { async tryAutoStart(): Promise { return this.withLifecycleLock('auto-start', async () => { // Sync first so the UI sees an accurate state even when the - // gateway is already running from a previous server process - // and we'd otherwise short-circuit later. Optional-chained so - // tests that mock `service.runtime` with a partial fake don't - // crash here. + // gateway is already running from a previous server process. await this.runtime.syncState?.() - await this.adoptRuntimeHostPort() const isSetUp = existsSync(this.getStateConfigPath()) if (!isSetUp) return logger.info('Attempting OpenClaw auto-start', { - hostPort: this.hostPort, + hostPort: this.runtime.getHostPort(), }) try { - await this.runtime.ensureReady() - - await this.ensureStateEnvFile() - - const persistedPort = await readPersistedGatewayPort(this.openclawDir) - if (persistedPort !== null) { - this.setPort(persistedPort) + if (this.runtime.getStatusSnapshot().state !== 'running') { + await this.runtime.executeAction({ type: 'start' }) } - - if (!(await this.isCurrentGatewayAvailable(this.hostPort))) { - await this.ensureGatewayPortAllocated() - await this.runtime.startGateway(undefined) - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - logger.warn('OpenClaw gateway failed to become ready on auto-start') - return - } - } - - // Sync the runtime's state machine to whatever the actual - // container is doing — short-circuit branches above don't - // drive the state transitions, so without this the UI sees - // `not_installed` for a gateway that's actually running. - await this.runtime.syncState?.() - await this.adoptRuntimeHostPort() - - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.cliClient.probe() await this.ensureAllCliProvidersInstalled() logger.info('OpenClaw gateway auto-started') } catch (err) { @@ -1040,120 +1007,6 @@ export class OpenClawService { }) } - private setPort(hostPort: number): void { - if (hostPort === this.hostPort) return - this.hostPort = hostPort - // Tests sometimes overwrite this.runtime with a partial mock that - // doesn't carry every method — guard so we don't crash when the - // mock omits setHostPort. - this.runtime.setHostPort?.(hostPort) - this.httpClient = new OpenClawHttpClient(this.hostPort) - } - - /** - * If runtime.syncState reconciled the host port from the live - * container mapping, adopt it on the service side and rewrite - * runtime-state.json so subsequent boots don't drift again. - */ - private async adoptRuntimeHostPort(): Promise { - const runtimePort = this.runtime.getHostPort?.() - if (typeof runtimePort !== 'number' || runtimePort === this.hostPort) { - return - } - logger.info('Adopting reconciled OpenClaw gateway host port', { - previous: this.hostPort, - actual: runtimePort, - }) - this.setPort(runtimePort) - try { - await writePersistedGatewayPort(this.openclawDir, runtimePort) - } catch (err) { - logger.warn('Failed to persist reconciled OpenClaw gateway port', { - port: runtimePort, - error: err instanceof Error ? err.message : String(err), - }) - } - } - - private async ensureGatewayPortAllocated( - logProgress?: (msg: string) => void, - ): Promise { - const persistedPort = await readPersistedGatewayPort(this.openclawDir) - if (persistedPort !== null) { - this.setPort(persistedPort) - } - const currentPortReady = await this.isGatewayPortReady(this.hostPort) - if ( - currentPortReady && - (await this.isGatewayAuthenticated(this.hostPort)) - ) { - return - } - if (currentPortReady) { - // Port is reachable but auth rejected — a stale gateway from a - // previous boot or token rotation owns it. Stop our container - // first so the upcoming start cycle actually creates a fresh - // one: ManagedContainer.start no-ops when state==='running', - // so without this the realloc would bump the persisted port - // while leaving the old container still bound to the old one. - logProgress?.('Stopping stale OpenClaw gateway before re-allocating port') - logger.info('Stopping stale OpenClaw gateway before re-allocating port', { - hostPort: this.hostPort, - }) - try { - await this.runtime.stopGateway?.() - } catch (err) { - logger.warn('Failed to stop stale OpenClaw gateway before realloc', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - const hostPort = await allocateGatewayPort(this.openclawDir, { - excludePort: currentPortReady ? this.hostPort : undefined, - }) - if (hostPort !== this.hostPort) { - logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`) - logger.info('Allocated OpenClaw gateway host port', { hostPort }) - this.setPort(hostPort) - } - } - - private async isGatewayAvailable(hostPort: number): Promise { - if (!(await this.isGatewayPortReady(hostPort))) return false - return this.isGatewayAuthenticated(hostPort) - } - - private async isGatewayAuthenticated(hostPort: number): Promise { - const client = - hostPort === this.hostPort - ? this.httpClient - : new OpenClawHttpClient(hostPort) - const authenticated = await client.isAuthenticated() - if (!authenticated) { - logger.warn('OpenClaw gateway readiness probe failed', { hostPort }) - } - return authenticated - } - - private async isCurrentGatewayAvailable(hostPort: number): Promise { - if (!(await this.isGatewayAvailable(hostPort))) return false - return this.runtime.isGatewayCurrent() - } - - private async isGatewayPortReady(hostPort: number): Promise { - // Route through the runtime's probe when the port matches its - // configured one — preserves the no-direct-fetch semantics the - // legacy adapter exposed (and that several tests rely on by - // mocking runtime.isReady but not the HTTP layer). - if (hostPort === this.hostPort) { - if (await this.runtime.isReady()) return true - const r = this.runtime as { isHealthy?: () => Promise } - return r.isHealthy ? r.isHealthy() : false - } - if (await fetchOk(`http://127.0.0.1:${hostPort}/readyz`)) return true - return fetchOk(`http://127.0.0.1:${hostPort}/healthz`) - } - private async assertGatewayReady(): Promise { if (await this.runtime.isReady()) return throw new Error('OpenClaw gateway is not ready') @@ -1219,8 +1072,8 @@ export class OpenClawService { { path: 'gateway.controlUi.allowedOrigins', value: [ - `http://127.0.0.1:${this.hostPort}`, - `http://localhost:${this.hostPort}`, + `http://127.0.0.1:${this.runtime.getHostPort()}`, + `http://localhost:${this.runtime.getHostPort()}`, ], }, { @@ -1341,7 +1194,7 @@ export class OpenClawService { private async waitForGatewayAfterCliMutation(): Promise { const ready = await this.runtime.waitForReady( - this.hostPort, + this.runtime.getHostPort(), READY_TIMEOUT_MS, ) if (!ready) { @@ -1531,15 +1384,6 @@ export function getOpenClawService(): OpenClawService { return service } -async function fetchOk(url: string): Promise { - try { - const res = await fetch(url) - return res.ok - } catch { - return false - } -} - /** Resolve the OpenClawContainerRuntime, registering it lazily if * main.ts didn't already do so (e.g. tests that build the service * directly). Always succeeds — the runtime constructs on every diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts index b6354d095..48675e898 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/runtime-state.ts @@ -8,7 +8,7 @@ * is reused across restarts when it's still free. */ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { createServer } from 'node:net' import { join } from 'node:path' @@ -46,20 +46,41 @@ export async function readPersistedGatewayPort( const parsed = JSON.parse( await readFile(path, 'utf-8'), ) as Partial - if ( - typeof parsed.gatewayPort === 'number' && - Number.isInteger(parsed.gatewayPort) && - parsed.gatewayPort > 0 && - parsed.gatewayPort <= MAX_TCP_PORT - ) { - return parsed.gatewayPort - } + return validateGatewayPort(parsed) + } catch { return null + } +} + +/** Sync sibling for callers that need the persisted port at construction + * time (i.e. the runtime constructor, which can't await). */ +export function readPersistedGatewayPortSync( + openclawDir: string, +): number | null { + const path = getRuntimeStatePath(openclawDir) + if (!existsSync(path)) return null + try { + const parsed = JSON.parse( + readFileSync(path, 'utf-8'), + ) as Partial + return validateGatewayPort(parsed) } catch { return null } } +function validateGatewayPort(parsed: Partial): number | null { + if ( + typeof parsed.gatewayPort === 'number' && + Number.isInteger(parsed.gatewayPort) && + parsed.gatewayPort > 0 && + parsed.gatewayPort <= MAX_TCP_PORT + ) { + return parsed.gatewayPort + } + return null +} + export async function writePersistedGatewayPort( openclawDir: string, port: number, diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts index 279c57152..cb6c4e121 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts @@ -13,6 +13,10 @@ import { OPENCLAW_IMAGE, } from '@browseros/shared/constants/openclaw' import { getOpenClawStateEnvPath } from '../../../api/services/openclaw/openclaw-env' +import { + readPersistedGatewayPortSync, + writePersistedGatewayPort, +} from '../../../api/services/openclaw/runtime-state' import { getBrowserosDir, getOpenClawDir } from '../../browseros-dir' import { ContainerCli } from '../../container/container-cli' import { ImageLoader } from '../../container/image-loader' @@ -88,17 +92,27 @@ export class OpenClawContainerRuntime extends ContainerAgentRuntime { ) { super(deps) this.openclawConfig = config - } - - /** Service owns port allocation; the runtime re-reads it at spec-build and probe time. */ - setHostPort(port: number): void { - this.hostPort = port + // Seed hostPort from the persisted runtime-state.json so the + // gateway comes up on the same port across server restarts (and + // Lima's port-forward keeps pointing at the same Mac-side port). + // syncState reconciles further drift at runtime. + const persisted = readPersistedGatewayPortSync( + this.openclawConfig.openclawDir, + ) + if (persisted !== null) this.hostPort = persisted } getHostPort(): number { return this.hostPort } + /** Test-only override; production reads/writes the port via the + * persisted runtime-state.json (seeded in the constructor and + * rewritten by syncState when the live container drifts). */ + setHostPort(port: number): void { + this.hostPort = port + } + // ── ManagedContainer abstracts ─────────────────────────────────── protected mountRoots(): readonly MountRoot[] { @@ -274,6 +288,17 @@ export class OpenClawContainerRuntime extends ContainerAgentRuntime { actual: mapped.hostPort, }) this.hostPort = mapped.hostPort + try { + await writePersistedGatewayPort( + this.openclawConfig.openclawDir, + mapped.hostPort, + ) + } catch (err) { + logger.warn('Failed to persist reconciled OpenClaw gateway port', { + port: mapped.hostPort, + error: err instanceof Error ? err.message : String(err), + }) + } } if (await fetchOk(`http://127.0.0.1:${this.hostPort}/readyz`)) { this.setState('running') diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index 62e0ebe43..f21de87c7 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -99,6 +99,7 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.runtime = { + getHostPort: () => 18789, ensureReady, isReady: async () => false, prewarmGatewayImage, @@ -126,6 +127,7 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.runtime = { + getHostPort: () => 18789, ensureReady, isReady: async () => false, prewarmGatewayImage, @@ -158,6 +160,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, isReady: async () => true, } service.cliClient = { @@ -200,6 +203,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, isReady: async () => true, } service.cliClient = { @@ -268,6 +272,7 @@ describe('OpenClawService', () => { steps.push('start') }) service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -351,6 +356,7 @@ describe('OpenClawService', () => { const restartGateway = mock(async () => {}) const startGateway = mock(async () => {}) service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -389,6 +395,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -460,6 +467,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -528,6 +536,7 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.runtime = { + getHostPort: () => 18789, isReady: async () => true, getGatewayLogs, } @@ -536,157 +545,53 @@ describe('OpenClawService', () => { expect(getGatewayLogs).toHaveBeenCalledWith(25) }) - it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => { + it('tryAutoStart delegates lifecycle to runtime.executeAction', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, - }, - }), - ) - const ensureReady = mock(async () => {}) - const isReady = mock(async () => false) - const startGateway = mock(async () => {}) - const waitForReady = mock(async () => true) - const probe = mock(async () => {}) - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - isPodmanAvailable: async () => true, - ensureReady, - isReady, - isGatewayCurrent: mock(async () => true), - startGateway, - waitForReady, - } - service.cliClient = { - probe, - } - - await service.tryAutoStart() - - expect(ensureReady).toHaveBeenCalledTimes(1) - expect(startGateway).toHaveBeenCalledTimes(1) - expect(waitForReady).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledTimes(1) - expect(isReady).toHaveBeenCalledTimes(2) - }) + await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') - it('tryAutoStart reuses a ready gateway when the image is current', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }), - ) - const ensureReady = mock(async () => {}) - const isReady = mock(async () => true) - const isGatewayCurrent = mock(async () => true) - const startGateway = mock(async () => {}) + const executeAction = mock(async () => {}) + const syncState = mock(async () => {}) const probe = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir service.runtime = { - ensureReady, - isReady, - isGatewayCurrent, - startGateway, + getHostPort: () => 18789, + getStatusSnapshot: () => ({ state: 'stopped' as const }), + syncState, + executeAction, } service.cliClient = { probe } - mockGatewayAuth() await service.tryAutoStart() - expect(ensureReady).toHaveBeenCalledTimes(1) - expect(isGatewayCurrent).toHaveBeenCalledTimes(1) - expect(startGateway).not.toHaveBeenCalled() + expect(syncState).toHaveBeenCalledTimes(1) + expect(executeAction).toHaveBeenCalledWith({ type: 'start' }) expect(probe).toHaveBeenCalledTimes(1) }) - it('tryAutoStart reuses a ready no-auth gateway without Authorization', async () => { + it('tryAutoStart skips start when runtime is already running', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - mode: 'none', - token: 'stale-token', - }, - }, - }), - ) - const ensureReady = mock(async () => {}) - const isReady = mock(async () => true) - const isGatewayCurrent = mock(async () => true) - const startGateway = mock(async () => {}) - const probe = mock(async () => {}) - const fetchMock = mock(() => - Promise.resolve(new Response('', { status: 200 })), - ) - globalThis.fetch = fetchMock as typeof globalThis.fetch - const service = new OpenClawService() as MutableOpenClawService - - service.openclawDir = tempDir - service.runtime = { - ensureReady, - isReady, - isGatewayCurrent, - startGateway, - } - service.cliClient = { probe } - - await service.tryAutoStart() - - expect(startGateway).not.toHaveBeenCalled() - expect(fetchMock.mock.calls[0]?.[0]).toBe( - 'http://127.0.0.1:18789/v1/models', - ) - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: 'GET', - }) - expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization') - expect(probe).toHaveBeenCalledTimes(1) - }) + await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') - it('tryAutoStart recreates a ready gateway when the image is stale', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }), - ) - const ensureReady = mock(async () => {}) - const isReady = mock(async () => true) - const isGatewayCurrent = mock(async () => false) - const startGateway = mock(async () => {}) - const waitForReady = mock(async () => true) + const executeAction = mock(async () => {}) const probe = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir service.runtime = { - ensureReady, - isReady, - isGatewayCurrent, - startGateway, - waitForReady, + getHostPort: () => 18789, + getStatusSnapshot: () => ({ state: 'running' as const }), + syncState: async () => {}, + executeAction, } service.cliClient = { probe } - mockGatewayAuth() await service.tryAutoStart() - expect(startGateway).toHaveBeenCalledTimes(1) - expect(waitForReady).toHaveBeenCalledTimes(1) + expect(executeAction).not.toHaveBeenCalled() expect(probe).toHaveBeenCalledTimes(1) }) @@ -822,6 +727,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, isReady: async () => true, } @@ -862,6 +768,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, isReady: async () => true, } @@ -910,6 +817,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, isReady: async () => true, } @@ -1010,6 +918,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, isReady: async () => true, } @@ -1091,6 +1000,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, isReady: async () => true, waitForReady: mock(async () => true), @@ -1176,6 +1086,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, isReady: async () => true, waitForReady: async () => true, @@ -1214,6 +1125,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, restartGateway: restart, } service.cliClient = { @@ -1235,16 +1147,3 @@ describe('OpenClawService', () => { ) }) }) - -function mockGatewayAuth(status = 200): ReturnType { - const fetchMock = mock(() => Promise.resolve(new Response('', { status }))) - globalThis.fetch = fetchMock as typeof globalThis.fetch - return fetchMock -} - -function fetchHeaders( - fetchMock: ReturnType, -): Record { - return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ?? - {}) as Record -} From 9632b604257114445ff5e9d459a5e2af22ce8f91 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 17:39:48 +0530 Subject: [PATCH 13/15] refactor(openclaw): delete legacy UI helpers + types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime state machine is now the single source of truth in the UI; the old OpenClawStatus surface (controlPlaneStatus, lastGatewayError, lastRecoveryReason, the status enum) and its consumers are all dead weight after Chunks 1-4. Drop them. UI: - OpenClawControls.tsx: delete StatusBadge, ControlPlaneBadge, AgentsPageHeader, LifecycleAlert, ControlPlaneAlert, GatewayStateCards. Keep ProviderSelector + InlineErrorAlert — still used by the setup dialog and AgentsPage's inline error surface. - agents-page-utils.ts: delete getControlPlaneCopy, getRecoveryDetail, getGatewayUiState, getLifecycleBanner, canManageOpenClawAgents, shouldShowControlPlaneDegraded, getControlPlaneCopyForStatus. - agents-page-types.ts: delete GatewayUiState, LIFECYCLE_BANNER_COPY, CONTROL_PLANE_COPY, FALLBACK_CONTROL_PLANE_COPY, RECOVERY_REASON_COPY. - useOpenClaw.ts: delete OpenClawStatus + GatewayLifecycleAction. --- .../app/agents/OpenClawControls.tsx | 297 +----------------- .../app/agents/agents-page-types.ts | 92 ------ .../app/agents/agents-page-utils.ts | 88 +----- .../entrypoints/app/agents/useOpenClaw.ts | 37 --- 4 files changed, 3 insertions(+), 511 deletions(-) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/OpenClawControls.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/OpenClawControls.tsx index 73801c816..9a2cdb14f 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/OpenClawControls.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/OpenClawControls.tsx @@ -1,20 +1,7 @@ -import { - AlertCircle, - Cpu, - Loader2, - Plus, - RefreshCw, - ShieldAlert, - Square, - TerminalSquare, - WifiOff, - Wrench, -} from 'lucide-react' +import { AlertCircle } from 'lucide-react' import type { FC } from 'react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Select, @@ -24,40 +11,6 @@ import { SelectValue, } from '@/components/ui/select' import type { ProviderOption } from './agents-page-types' -import { - CONTROL_PLANE_COPY, - FALLBACK_CONTROL_PLANE_COPY, -} from './agents-page-types' -import type { getControlPlaneCopy } from './agents-page-utils' -import type { OpenClawStatus } from './useOpenClaw' - -const StatusBadge: FC<{ status: OpenClawStatus['status'] }> = ({ status }) => { - const variants: Record< - OpenClawStatus['status'], - { - variant: 'default' | 'secondary' | 'outline' | 'destructive' - label: string - } - > = { - running: { variant: 'default', label: 'Running' }, - starting: { variant: 'secondary', label: 'Starting...' }, - stopped: { variant: 'outline', label: 'Stopped' }, - error: { variant: 'destructive', label: 'Error' }, - uninitialized: { variant: 'outline', label: 'Not Set Up' }, - } - const current = variants[status] ?? { - variant: 'outline' as const, - label: 'Unknown', - } - return {current.label} -} - -const ControlPlaneBadge: FC<{ - status: OpenClawStatus['controlPlaneStatus'] -}> = ({ status }) => { - const current = CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY - return {current.badgeLabel} -} interface ProviderSelectorProps { providers: ProviderOption[] @@ -115,112 +68,6 @@ export const ProviderSelector: FC = ({ ) } -interface AgentsPageHeaderProps { - actionInProgress: boolean - controlPlaneBusy: boolean - reconnecting: boolean - status: OpenClawStatus | null - onCreateAgent: () => void - onOpenTerminal: () => void - onReconnect: () => void - onRefresh: () => void - onRestart: () => void - onStop: () => void -} - -export const AgentsPageHeader: FC = ({ - actionInProgress, - controlPlaneBusy, - reconnecting, - status, - onCreateAgent, - onOpenTerminal, - onReconnect, - onRefresh, - onRestart, - onStop, -}) => ( -
-
-

Agents

-

- OpenClaw, Claude Code, and Codex agents -

-
- -
- {status ? ( - <> - - {status.status !== 'uninitialized' && ( - - )} - - ) : null} - - {status?.status === 'running' && - status.controlPlaneStatus !== 'connected' ? ( - - ) : null} - - {status?.status === 'running' ? ( - <> - - - - - ) : null} - - - -
-
-) - -export function LifecycleAlert({ message }: { message: string }) { - return ( - - - {message} - - ) -} - export function InlineErrorAlert({ message, onDismiss, @@ -243,145 +90,3 @@ export function InlineErrorAlert({ ) } - -interface ControlPlaneAlertProps { - actionInProgress: boolean - controlPlaneBusy: boolean - controlPlaneCopy: ReturnType - reconnecting: boolean - recoveryDetail: string | null - status: OpenClawStatus - onReconnect: () => void - onRestart: () => void -} - -export const ControlPlaneAlert: FC = ({ - actionInProgress, - controlPlaneBusy, - controlPlaneCopy, - reconnecting, - recoveryDetail, - status, - onReconnect, - onRestart, -}) => ( - - {status.controlPlaneStatus === 'failed' ? ( - - ) : status.controlPlaneStatus === 'recovering' ? ( - - ) : ( - - )} - {controlPlaneCopy.title} - -

{controlPlaneCopy.description}

- {recoveryDetail ?

{recoveryDetail}

: null} -
- - -
-
-
-) - -interface GatewayStateCardsProps { - actionInProgress: boolean - status: OpenClawStatus | null - onOpenSetup: () => void - onRestart: () => void - onStart: () => void -} - -export const GatewayStateCards: FC = ({ - actionInProgress, - status, - onOpenSetup, - onRestart, - onStart, -}) => ( - <> - {status?.status === 'uninitialized' ? ( - - - -
-

Set Up OpenClaw

-

- {status.podmanAvailable - ? 'Create a local BrowserOS VM to run autonomous agents with full tool access.' - : 'BrowserOS VM runtime is unavailable on this system.'} -

-
- {status.podmanAvailable ? ( - - ) : null} -
-
- ) : null} - - {status?.status === 'stopped' ? ( - - - -
-

Gateway Stopped

-

- The OpenClaw gateway is not running. -

-
- -
-
- ) : null} - - {status?.status === 'error' ? ( - - - -
-

Gateway Error

-

- {status.error ?? status.lastGatewayError} -

-
-
- - -
-
-
- ) : null} - -) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts index 2559ff9b3..ddcd7e361 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-types.ts @@ -1,5 +1,4 @@ import type { HarnessAgentAdapter } from './agent-harness-types' -import type { GatewayLifecycleAction, OpenClawStatus } from './useOpenClaw' export type CreateAgentRuntime = 'openclaw' | HarnessAgentAdapter @@ -24,96 +23,5 @@ export interface AgentListItem { canDelete: boolean } -export interface GatewayUiState { - canManageAgents: boolean - controlPlaneDegraded: boolean - controlPlaneBusy: boolean -} - export const DEFAULT_HARNESS_ADAPTER: HarnessAgentAdapter = 'claude' export const DEFAULT_CREATE_RUNTIME: CreateAgentRuntime = 'openclaw' - -export const LIFECYCLE_BANNER_COPY: Record = { - setup: 'Setting up OpenClaw...', - start: 'Starting gateway...', - stop: 'Stopping gateway...', - restart: 'Restarting gateway...', - reconnect: 'Restoring gateway connection...', -} - -export const CONTROL_PLANE_COPY: Record< - OpenClawStatus['controlPlaneStatus'], - { - badgeVariant: 'default' | 'secondary' | 'outline' | 'destructive' - badgeLabel: string - title: string - description: string - } -> = { - connected: { - badgeVariant: 'default', - badgeLabel: 'Control Plane Ready', - title: 'Gateway Connected', - description: 'OpenClaw can create, manage, and chat with agents normally.', - }, - connecting: { - badgeVariant: 'secondary', - badgeLabel: 'Connecting', - title: 'Connecting to Gateway', - description: - 'BrowserOS is establishing the OpenClaw control channel for agent operations.', - }, - reconnecting: { - badgeVariant: 'secondary', - badgeLabel: 'Reconnecting', - title: 'Reconnecting Control Plane', - description: - 'The gateway process is up, but BrowserOS is restoring the control channel.', - }, - recovering: { - badgeVariant: 'secondary', - badgeLabel: 'Recovering', - title: 'Recovering Gateway Connection', - description: - 'BrowserOS detected a control-plane fault and is trying a safe recovery path.', - }, - disconnected: { - badgeVariant: 'outline', - badgeLabel: 'Disconnected', - title: 'Gateway Disconnected', - description: 'The gateway process is not available to BrowserOS right now.', - }, - failed: { - badgeVariant: 'destructive', - badgeLabel: 'Needs Attention', - title: 'Gateway Recovery Failed', - description: - 'BrowserOS could not restore the OpenClaw control channel automatically.', - }, -} - -export const FALLBACK_CONTROL_PLANE_COPY = { - badgeVariant: 'outline' as const, - badgeLabel: 'Unknown', - title: 'Gateway State Unknown', - description: - 'BrowserOS received a gateway status it does not recognize yet. Refreshing or reconnecting should restore a known state.', -} - -export const RECOVERY_REASON_COPY: Record< - NonNullable, - string -> = { - transient_disconnect: - 'The control channel dropped briefly and BrowserOS is retrying it.', - signature_expired: - 'The gateway rejected the signed device handshake because its clock drifted.', - pairing_required: - 'The gateway asked BrowserOS to approve its local device identity again.', - token_mismatch: - 'BrowserOS had to reload the gateway token before reconnecting.', - container_not_ready: - 'The OpenClaw gateway process is not ready yet, so control-plane recovery cannot start.', - unknown: - 'BrowserOS hit an unexpected gateway error and could not classify it cleanly.', -} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts index 44101abf4..f0db13e16 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agents-page-utils.ts @@ -1,41 +1,8 @@ import type { LlmProviderConfig } from '@/lib/llm-providers/types' import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types' -import { - type AgentListItem, - CONTROL_PLANE_COPY, - FALLBACK_CONTROL_PLANE_COPY, - type GatewayUiState, - LIFECYCLE_BANNER_COPY, - type ProviderOption, - RECOVERY_REASON_COPY, -} from './agents-page-types' +import type { AgentListItem, ProviderOption } from './agents-page-types' import { getOpenClawSupportedProviders } from './openclaw-supported-providers' -import { - type AgentEntry, - type GatewayLifecycleAction, - getModelDisplayName, - type OpenClawStatus, -} from './useOpenClaw' - -export function getControlPlaneCopy( - status: OpenClawStatus['controlPlaneStatus'], -) { - return CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY -} - -export function getRecoveryDetail(status: OpenClawStatus): string | null { - if (!status.lastRecoveryReason && !status.lastGatewayError) return null - - const detail = status.lastRecoveryReason - ? RECOVERY_REASON_COPY[status.lastRecoveryReason] - : null - - if (status.lastGatewayError && detail) { - return `${detail} Latest gateway error: ${status.lastGatewayError}` - } - - return status.lastGatewayError ?? detail -} +import { type AgentEntry, getModelDisplayName } from './useOpenClaw' export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string { return adapter === 'claude' ? 'Claude Code' : 'Codex' @@ -79,57 +46,6 @@ export function toHarnessListItem(agent: HarnessAgent): AgentListItem { } } -export function getGatewayUiState( - status: OpenClawStatus | null, -): GatewayUiState { - if (!status) { - return { - canManageAgents: false, - controlPlaneDegraded: false, - controlPlaneBusy: false, - } - } - - const controlPlaneBusy = - status.controlPlaneStatus === 'connecting' || - status.controlPlaneStatus === 'reconnecting' || - status.controlPlaneStatus === 'recovering' - - return { - canManageAgents: - status.status === 'running' && status.controlPlaneStatus === 'connected', - controlPlaneBusy, - controlPlaneDegraded: - status.status === 'running' && status.controlPlaneStatus !== 'connected', - } -} - -export function getLifecycleBanner( - action: GatewayLifecycleAction | null, -): string | null { - return action ? LIFECYCLE_BANNER_COPY[action] : null -} - -export function canManageOpenClawAgents( - state: GatewayUiState, - lifecyclePending: boolean, -): boolean { - return state.canManageAgents && !lifecyclePending -} - -export function shouldShowControlPlaneDegraded( - state: GatewayUiState, - lifecyclePending: boolean, -): boolean { - return state.controlPlaneDegraded && !lifecyclePending -} - -export function getControlPlaneCopyForStatus(status: OpenClawStatus | null) { - return status - ? getControlPlaneCopy(status.controlPlaneStatus) - : FALLBACK_CONTROL_PLANE_COPY -} - export function getVisibleOpenClawAgents( enabled: boolean, agents: AgentEntry[], diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts index 94cb585ec..13cc6397e 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts @@ -9,36 +9,6 @@ export interface AgentEntry { source?: 'openclaw' | 'agent-harness' } -/** - * Vestige type kept so the legacy UI helpers in agents-page-utils + - * OpenClawControls + agents-page-types still compile. Those files are - * the next deletion target — once they're gone this can vanish too. - */ -export interface OpenClawStatus { - status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error' - podmanAvailable: boolean - machineReady: boolean - port: number | null - agentCount: number - error: string | null - controlPlaneStatus: - | 'disconnected' - | 'connecting' - | 'connected' - | 'reconnecting' - | 'recovering' - | 'failed' - lastGatewayError: string | null - lastRecoveryReason: - | 'transient_disconnect' - | 'signature_expired' - | 'pairing_required' - | 'token_mismatch' - | 'container_not_ready' - | 'unknown' - | null -} - export interface OpenClawAgentMutationInput { name: string providerType?: string @@ -70,13 +40,6 @@ export const OPENCLAW_QUERY_KEYS = { agents: 'openclaw-agents', } as const -export type GatewayLifecycleAction = - | 'setup' - | 'start' - | 'stop' - | 'restart' - | 'reconnect' - async function clawFetch( baseUrl: string, path: string, From b6172a41095dfd3618ee2c938fc72810745fb080 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 20:15:00 +0530 Subject: [PATCH 14/15] feat(agents): per-runtime install/start controls via RuntimesSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents page only surfaced OpenClaw's lifecycle controls — Hermes auto-installed silently at boot with no UI visibility or manual handle. Adds a generic section that iterates over container-kind runtimes from /runtimes and renders a control panel + status bar per adapter. - new useRuntimes() hook hits GET /runtimes - new RuntimesSection renders one card per container runtime, with an adapter-keyed extras registry for adapter-specific affordances (panel extras + status-bar pill / actions) - AgentsPage replaces its hand-rolled openclaw panel + bar with the section, plugging Configure-provider + Terminal into the openclaw slot via the registry - the section becomes adapter-agnostic: new container runtimes show up on the page automatically (filtered by descriptor.kind === 'container') --- .../entrypoints/app/agents/AgentsPage.tsx | 63 +++++++--------- .../runtime-controls/RuntimesSection.tsx | 72 +++++++++++++++++++ .../entrypoints/app/agents/useRuntime.ts | 18 +++++ 3 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimesSection.tsx diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx index 27861255f..f347381c8 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -28,8 +28,7 @@ import { } from './agents-page-utils' import { NewAgentDialog } from './NewAgentDialog' import { InlineErrorAlert } from './OpenClawControls' -import { RuntimeControlPanel } from './runtime-controls/RuntimeControlPanel' -import { RuntimeStatusBar } from './runtime-controls/RuntimeStatusBar' +import { RuntimesSection } from './runtime-controls/RuntimesSection' import { SetupOpenClawDialog } from './SetupOpenClawDialog' import { useAgentAdapters, @@ -261,13 +260,6 @@ export const AgentsPage: FC = () => { ) } - // Bar only makes sense when the gateway is running AND there's at - // least one OpenClaw agent in the merged list. Hide it for - // Claude/Codex-only setups so the page stays uncluttered. - const showGatewayStatusBar = - openClawRunning && - (visibleOpenClawAgents.length > 0 || - harnessAgents.some((agent) => agent.adapter === 'openclaw')) // Setup CTA appears when the runtime is healthy but the user has not // yet configured a provider (no openclaw.json on disk → runtime is // running but agent CRUD will fail). For now: surface it whenever the @@ -287,37 +279,32 @@ export const AgentsPage: FC = () => { /> ) : null} - setSetupOpen(true)} - > - Configure provider… - - ) : null - } + setSetupOpen(true)} + > + Configure provider… + + ) : null, + statusBarExtraActions: ( + + ), + }, + }} /> - {showGatewayStatusBar ? ( - setShowTerminal(true)} - > - - Terminal - - } - /> - ) : null} - > +} + +/** Renders one card per container-kind runtime (openclaw, hermes, …) + * with state-appropriate Install / Start / Restart controls and a + * status bar. Adapter-specific affordances slot in via `extras`. */ +export const RuntimesSection: FC = ({ extras }) => { + const { data, isLoading } = useRuntimes() + if (isLoading || !data) return null + + const containerRuntimes = data.filter( + (r) => r.descriptor.kind === 'container', + ) + if (containerRuntimes.length === 0) return null + + return ( +
+ {containerRuntimes.map((runtime) => ( + + ))} +
+ ) +} + +interface RuntimeCardProps { + runtime: RuntimeView + extras?: RuntimeAdapterExtras +} + +const RuntimeCard: FC = ({ runtime, extras }) => { + const adapter = runtime.descriptor.adapterId as RuntimeAdapterId + const showStatusBar = runtime.status.state === 'running' + + return ( +
+ + {showStatusBar && ( + + )} +
+ ) +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts index df45f99ea..de133e3fb 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts @@ -56,6 +56,24 @@ export const RUNTIME_QUERY_KEYS = { logs: (adapter: RuntimeAdapterId) => ['runtime-logs', adapter] as const, } as const +export function useRuntimes(opts: { pollMs?: number } = {}) { + const rpcClient = useRpcClient() + return useQuery({ + queryKey: [RUNTIME_QUERY_KEYS.list], + queryFn: async () => { + const res = await rpcClient.runtimes.$get() + if (!res.ok) { + const body = (await res.json()) as { error?: string } + throw new Error(body.error ?? 'runtimes list fetch failed') + } + const { runtimes } = (await res.json()) as { runtimes: RuntimeView[] } + return runtimes + }, + refetchInterval: opts.pollMs ?? 5_000, + retry: false, + }) +} + export function useRuntime( adapter: RuntimeAdapterId, opts: { pollMs?: number; enabled?: boolean } = {}, From e77031025b43bac2dc89f85bb8050472fdf6e8d1 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Mon, 11 May 2026 20:41:43 +0530 Subject: [PATCH 15/15] fix(container): poll readiness probe within descriptor budget on start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManagedContainer.start was firing the subclass `readinessProbe()` exactly once, the moment containerd reported the container as Up. For OpenClaw this raced the Node.js gateway's HTTP listener bind — containerd flips status as soon as the entrypoint process spawns, but the Express server takes a few hundred ms to start serving /readyz. Single-shot probe → unlucky → state='errored' with "Readiness probe failed after container reached running state". Pre-refactor (dev branch) didn't hit this because openclaw used a two-phase flow: `runtime.startGateway` (no probe) then `service.waitForReady` (polled /readyz for 30s). When the new runtime architecture folded openclaw under ManagedContainer, the polling was lost. Bring it into the base class: `ManagedContainer.start` now polls `readinessProbe()` within `descriptor.readinessProbe.timeoutMs` at `intervalMs` cadence. Deterministic probes (Hermes' `--version` exec) succeed on the first call and exit immediately — no extra latency. HTTP probes get the full budget they need. Also stops misapplying `descriptor.readinessProbe` to the containerd "Up" wait (which only takes ~50ms anyway — defaults are fine). --- .../container/managed/managed-container.ts | 34 ++++++++++++-- .../runtime/hermes-container-runtime.test.ts | 6 +++ .../managed/managed-container.test.ts | 47 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/browseros-agent/apps/server/src/lib/container/managed/managed-container.ts b/packages/browseros-agent/apps/server/src/lib/container/managed/managed-container.ts index 388e11da3..5c98480aa 100644 --- a/packages/browseros-agent/apps/server/src/lib/container/managed/managed-container.ts +++ b/packages/browseros-agent/apps/server/src/lib/container/managed/managed-container.ts @@ -200,14 +200,20 @@ export abstract class ManagedContainer { }) await this.deps.cli.createContainer(spec, log) await this.deps.cli.startContainer(spec.name, log) + await this.deps.cli.waitForContainerRunning(spec.name) + // Poll the subclass-defined readiness probe within the + // descriptor's budget. The container being "Up" in containerd + // only means the entrypoint process spawned — for HTTP probes + // (e.g. openclaw's /readyz) the listener can take a few hundred + // ms after that to bind. For deterministic probes (e.g. hermes' + // `--version` exec) the first call succeeds and the loop exits + // immediately. Subclasses without a transient-readiness phase + // pay nothing extra. const probeOpts = this.descriptor.readinessProbe - await this.deps.cli.waitForContainerRunning(spec.name, { + const probeOk = await this.pollReadinessProbe({ timeoutMs: probeOpts?.timeoutMs ?? 30_000, intervalMs: probeOpts?.intervalMs ?? 500, }) - // Run the subclass-defined probe — usually a `--version` exec - // or HTTP /readyz call. Failing this is errored, not stopped. - const probeOk = await this.readinessProbe() if (!probeOk) { this.setState( 'errored', @@ -228,6 +234,26 @@ export abstract class ManagedContainer { }) } + /** Poll the subclass `readinessProbe` until it returns true, errors + * swallowed (treated as not-yet-ready), or the timeout elapses. */ + private async pollReadinessProbe(opts: { + timeoutMs: number + intervalMs: number + }): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt <= opts.timeoutMs) { + try { + if (await this.readinessProbe()) return true + } catch { + // Treat thrown probes as transient — keep polling within budget. + } + const remainingMs = opts.timeoutMs - (Date.now() - startedAt) + if (remainingMs <= 0) break + await Bun.sleep(Math.min(opts.intervalMs, remainingMs)) + } + return false + } + /** Stop and remove the container. Image and per-agent data preserved. */ async stop(): Promise { return this.withLifecycleLock('stop', async () => { diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts index fe792fc30..2f2b2d25d 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts @@ -161,6 +161,12 @@ describe('HermesContainerRuntime', () => { browserosDir: '/host/browseros', hermesHarnessHostDir: '/host/browseros/vm/hermes/harness', }) + // Tight budget so the polling loop doesn't drag the test out for + // the full 30s readinessProbe.timeoutMs. + ;(runtime.descriptor as { readinessProbe?: unknown }).readinessProbe = { + timeoutMs: 100, + intervalMs: 10, + } await expect(runtime.start()).rejects.toThrow(/probe failed/i) expect(runtime.getState()).toBe('errored') }) diff --git a/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts b/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts index f16ad0344..d494f90c4 100644 --- a/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/container/managed/managed-container.test.ts @@ -48,6 +48,8 @@ class TestContainer extends ManagedContainer { defaultImage: 'docker.io/test:latest', containerName: 'test-container', platforms: ['darwin' as NodeJS.Platform], + // Snappy budget so probe-fails tests don't drag the suite. + readinessProbe: { timeoutMs: 100, intervalMs: 10 }, } probeOutcome: boolean | Error = true @@ -170,6 +172,51 @@ describe('ManagedContainer', () => { expect(c.getStatusSnapshot().lastError).toMatch(/probe failed/i) }) + it('polls the readiness probe until it succeeds within the descriptor budget', async () => { + const lockDir = mkTempDir() + const deps = makeFakeDeps({ lockDir }) + const c = new TestContainer(deps) + // Tight budget so the test stays snappy even on slow CI. + ;(c.descriptor as { readinessProbe?: unknown }).readinessProbe = { + timeoutMs: 200, + intervalMs: 20, + } + // Probe fails the first two calls (mimics the HTTP-listener race + // openclaw's /readyz hits), then flips to success. + c.probeOutcome = false + let calls = 0 + const original = c.readinessProbe.bind(c) + ;( + c as unknown as { readinessProbe: () => Promise } + ).readinessProbe = async () => { + calls += 1 + if (calls < 3) return false + return original.call(c) + } + c.probeOutcome = true + + await c.start() + + expect(c.getState()).toBe('running') + expect(calls).toBeGreaterThanOrEqual(3) + }) + + it('times out when the probe never succeeds, with errored state', async () => { + const lockDir = mkTempDir() + const deps = makeFakeDeps({ lockDir }) + const c = new TestContainer(deps) + ;(c.descriptor as { readinessProbe?: unknown }).readinessProbe = { + timeoutMs: 80, + intervalMs: 20, + } + c.probeOutcome = false + + await expect(c.start()).rejects.toThrow(/probe failed/i) + expect(c.getState()).toBe('errored') + // Should have polled multiple times within the budget, not just once. + expect(c.probeCalls).toBeGreaterThanOrEqual(2) + }) + it('stop() force-transitions to stopped even from errored', async () => { const lockDir = mkTempDir() const deps = makeFakeDeps({ lockDir })