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 91faca66f..f347381c8 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -1,6 +1,7 @@ -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 { Button } from '@/components/ui/button' import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' import { AgentList } from './AgentList' import { AgentsHeader } from './AgentsHeader' @@ -19,26 +20,15 @@ import { DEFAULT_HARNESS_ADAPTER, } from './agents-page-types' import { - canManageOpenClawAgents, getAgentsLoading, - getControlPlaneCopyForStatus, - getGatewayUiState, getInlineError, - getLifecycleBanner, - getRecoveryDetail, getVisibleOpenClawAgents, - shouldShowControlPlaneDegraded, toHarnessListItem, toOpenClawListItem, } from './agents-page-utils' -import { GatewayStatusBar } from './GatewayStatusBar' import { NewAgentDialog } from './NewAgentDialog' -import { - ControlPlaneAlert, - GatewayStateCards, - InlineErrorAlert, - LifecycleAlert, -} from './OpenClawControls' +import { InlineErrorAlert } from './OpenClawControls' +import { RuntimesSection } from './runtime-controls/RuntimesSection' import { SetupOpenClawDialog } from './SetupOpenClawDialog' import { useAgentAdapters, @@ -48,6 +38,7 @@ import { useUpdateHarnessAgent, } from './useAgents' import { useOpenClawAgents, useOpenClawMutations } from './useOpenClaw' +import { useRuntime } from './useRuntime' export const AgentsPage: FC = () => { const navigate = useNavigate() @@ -58,19 +49,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, @@ -83,15 +70,9 @@ export const AgentsPage: FC = () => { setupOpenClaw, createAgent: createOpenClawAgent, deleteAgent: deleteOpenClawAgent, - startOpenClaw, - restartOpenClaw, - reconnectOpenClaw, - actionInProgress, settingUp, creating: creatingOpenClawAgent, deleting: deletingOpenClawAgent, - reconnecting, - pendingGatewayAction, } = useOpenClawMutations() const [setupOpen, setSetupOpen] = useState(false) @@ -153,12 +134,10 @@ export const AgentsPage: FC = () => { setHarnessReasoningEffort, }) - const lifecyclePending = pendingGatewayAction !== null - 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, @@ -211,7 +190,7 @@ export const AgentsPage: FC = () => { return map }, [harnessAgents]) const inlineError = getInlineError({ - lifecyclePending, + lifecyclePending: false, pageError, openClawAgentsError, adaptersError, @@ -232,31 +211,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)} /> @@ -274,7 +252,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 (
@@ -282,29 +260,18 @@ export const AgentsPage: FC = () => { ) } - const showControlPlaneDegraded = shouldShowControlPlaneDegraded( - gatewayUiState, - lifecyclePending, - ) - const lifecycleBanner = getLifecycleBanner(pendingGatewayAction) - const recoveryDetail = status ? getRecoveryDetail(status) : null - const controlPlaneCopy = getControlPlaneCopyForStatus(status) - - // 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. - const showGatewayStatusBar = - status?.status === 'running' && - (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 (
setCreateOpen(true)} /> - {lifecycleBanner ? : null} - {inlineError ? ( { /> ) : null} - {status && showControlPlaneDegraded ? ( - { - void runWithPageErrorHandling(reconnectOpenClaw) - }} - onRestart={() => { - void runWithPageErrorHandling(restartOpenClaw) - }} - /> - ) : null} - - setSetupOpen(true)} - onRestart={() => { - void runWithPageErrorHandling(restartOpenClaw) - }} - onStart={() => { - void runWithPageErrorHandling(startOpenClaw) + setSetupOpen(true)} + > + Configure provider… + + ) : null, + statusBarExtraActions: ( + + ), + }, }} /> - {showGatewayStatusBar ? ( - setShowTerminal(true)} - onRestart={() => { - void runWithPageErrorHandling(restartOpenClaw) - }} - /> - ) : null} - { }) }} /> - { onProviderChange={setSetupProviderId} onSetup={() => void handleSetup()} /> - 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/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/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.'} -
-
-
- )}
) } 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/runtime-controls/RuntimeControlPanel.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx new file mode 100644 index 000000000..d0e582e6d --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimeControlPanel.tsx @@ -0,0 +1,235 @@ +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' || state === 'installed') && 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', + } + } +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimesSection.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimesSection.tsx new file mode 100644 index 000000000..7bbe89b9c --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/runtime-controls/RuntimesSection.tsx @@ -0,0 +1,72 @@ +import type { FC, ReactNode } from 'react' +import { + type RuntimeAdapterId, + type RuntimeView, + useRuntimes, +} from '../useRuntime' +import { RuntimeControlPanel } from './RuntimeControlPanel' +import { RuntimeStatusBar } from './RuntimeStatusBar' + +/** Optional adapter-specific UI hooks. Each runtime can plug in extras + * for the control panel (e.g. openclaw's "Configure provider…") and + * the status bar (extraPill, extraActions). Missing keys fall back to + * the generic panel/bar with no extras. */ +export interface RuntimeAdapterExtras { + panelExtras?: ReactNode + statusBarExtraPill?: ReactNode + statusBarExtraActions?: ReactNode +} + +interface RuntimesSectionProps { + /** Per-adapter customization keyed by adapterId. Adapters not in the + * map render the generic UI. */ + extras?: Partial> +} + +/** 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/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 75b4f2266..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,31 +9,6 @@ export interface AgentEntry { source?: 'openclaw' | 'agent-harness' } -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 @@ -62,17 +37,9 @@ export function getModelDisplayName(model: unknown): string | undefined { } export const OPENCLAW_QUERY_KEYS = { - status: 'openclaw-status', agents: 'openclaw-agents', } as const -export type GatewayLifecycleAction = - | 'setup' - | 'start' - | 'stop' - | 'restart' - | 'reconnect' - async function clawFetch( baseUrl: string, path: string, @@ -92,10 +59,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 +70,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) { @@ -201,66 +141,17 @@ export function useOpenClawMutations() { onSuccess, }) - const startMutation = useMutation({ - mutationFn: async () => - 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/agent/entrypoints/app/agents/useRuntime.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts new file mode 100644 index 000000000..de133e3fb --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useRuntime.ts @@ -0,0 +1,149 @@ +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 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 } = {}, +) { + 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, + }) +} 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 00e4998dd..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) @@ -111,54 +106,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/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..942faa2a8 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( @@ -147,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 @@ -237,6 +241,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/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 1b2f0e8a2..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,50 +69,12 @@ import { type ResolvedOpenClawProviderConfig, resolveSupportedOpenClawProvider, } from './openclaw-provider-map' -import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state' const READY_TIMEOUT_MS = 30_000 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' - -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 { @@ -357,16 +319,11 @@ 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 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() @@ -377,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 @@ -407,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. */ @@ -510,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, @@ -532,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, @@ -560,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) { @@ -570,7 +529,6 @@ export class OpenClawService { throw new Error(this.lastError) } - this.controlPlaneStatus = 'connecting' logProgress('Probing OpenClaw control plane...') await this.runControlPlaneCall(() => this.cliClient.probe()) @@ -595,194 +553,14 @@ export class OpenClawService { this.lastError = null logProgress( - `OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`, - ) - logger.info('OpenClaw setup complete', { hostPort: this.hostPort }) - }) - } - - 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, + `OpenClaw gateway running at http://127.0.0.1:${this.runtime.getHostPort()}`, ) - 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, + logger.info('OpenClaw setup complete', { + hostPort: this.runtime.getHostPort(), }) - - 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 { - const isSetUp = existsSync(this.getStateConfigPath()) - if (!isSetUp) { - const machineStatus = await this.runtime.getMachineStatus() - return { - status: 'uninitialized', - podmanAvailable: true, - machineReady: machineStatus.running, - port: null, - agentCount: 0, - error: null, - controlPlaneStatus: 'disconnected', - lastGatewayError: this.lastGatewayError, - lastRecoveryReason: this.lastRecoveryReason, - } - } - - const machineStatus = await this.runtime.getMachineStatus() - const ready = machineStatus.running ? await this.runtime.isReady() : false - - let agentCount = 0 - if (ready) { - try { - const agents = await this.runControlPlaneCall(() => - this.cliClient.listAgents(), - ) - agentCount = agents.length - } catch { - // latest control plane error is captured by runControlPlaneCall - } - } - - return { - status: ready ? 'running' : this.lastError ? 'error' : 'stopped', - podmanAvailable: true, - machineReady: machineStatus.running, - port: this.hostPort, - agentCount, - error: this.lastError, - controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected', - lastGatewayError: this.lastGatewayError, - lastRecoveryReason: this.lastRecoveryReason, - } - } - // ── Agent Management (via CLI) ────────────────────────────────────── async createAgent(input: { @@ -820,7 +598,7 @@ export class OpenClawService { configChanged, keysChanged, }) - await this.restart() + await this.runtime.restartGateway(undefined) } const model = provider.model @@ -1084,7 +862,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 @@ -1123,37 +901,22 @@ 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. + await this.runtime.syncState?.() + 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 (!(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 - } + if (this.runtime.getStatusSnapshot().state !== 'running') { + await this.runtime.executeAction({ type: 'start' }) } - - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.cliClient.probe() await this.ensureAllCliProvidersInstalled() logger.info('OpenClaw gateway auto-started') } catch (err) { @@ -1244,116 +1007,13 @@ 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) - } - - 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 - } - 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 { - 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 { @@ -1371,16 +1031,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) } @@ -1422,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()}`, ], }, { @@ -1544,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) { @@ -1734,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 2df49639d..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,21 +46,42 @@ 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 } } -async function writePersistedGatewayPort( +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, ): 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 66800a029..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 @@ -4,13 +4,19 @@ * 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, 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' @@ -86,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[] { @@ -112,6 +128,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 { @@ -236,6 +261,60 @@ 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. 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( + this.descriptor.containerName, + ) + if (!info) { + if (this.state !== 'not_installed') this.setState('not_installed') + 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 + 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') + 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 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/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/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/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/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) + }) + }) +}) 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..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 @@ -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, @@ -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 = { @@ -239,49 +243,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, - } - 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('creates the main agent during setup when the gateway starts without one', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) const steps: string[] = [] @@ -311,6 +272,7 @@ describe('OpenClawService', () => { steps.push('start') }) service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -394,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, @@ -432,6 +395,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -503,6 +467,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { + getHostPort: () => 18789, isPodmanAvailable: async () => true, ensureReady: async () => {}, isReady: async () => true, @@ -566,359 +531,12 @@ 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('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 service.runtime = { + getHostPort: () => 18789, isReady: async () => true, getGatewayLogs, } @@ -927,193 +545,53 @@ 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 () => { + 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) }) @@ -1248,8 +726,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1288,8 +767,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1336,8 +816,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1436,8 +917,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, isReady: async () => true, } service.cliClient = { @@ -1484,7 +966,6 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart await expect( service.updateProviderKeys({ @@ -1518,8 +999,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, isReady: async () => true, waitForReady: mock(async () => true), } @@ -1577,7 +1059,6 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart await service.updateProviderKeys({ providerType: 'openai', @@ -1604,8 +1085,9 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, isReady: async () => true, waitForReady: async () => true, } @@ -1642,7 +1124,10 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.restart = restart + service.runtime = { + getHostPort: () => 18789, + restartGateway: restart, + } service.cliClient = { setDefaultModel, } @@ -1662,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 -} 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..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 @@ -54,6 +54,7 @@ function makeDeps(opts: { image: HERMES_IMAGE, status: 'running', running: true, + ports: [], }), removeContainer: async () => {}, waitForContainerNameRelease: async () => {}, @@ -160,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/agents/runtime/openclaw-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts index b4987c46c..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 @@ -50,6 +50,7 @@ function makeDeps(opts: { lockDir: string }): { image: 'docker.io/openclaw:latest', status: 'running', running: true, + ports: [], }), removeContainer: async () => {}, waitForContainerNameRelease: async () => {}, @@ -105,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', () => { @@ -125,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', }, @@ -226,6 +225,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..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 @@ -96,6 +98,7 @@ function makeFakeDeps(opts: { lockDir: string }): ManagedContainerDeps & { image: 'docker.io/test:latest', status: 'running', running: true, + ports: [], }), removeContainer: async () => {}, waitForContainerNameRelease: async () => {}, @@ -169,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 })