Compare commits

...

22 Commits

Author SHA1 Message Date
DaniAkash
e77031025b fix(container): poll readiness probe within descriptor budget on start
ManagedContainer.start was firing the subclass `readinessProbe()`
exactly once, the moment containerd reported the container as Up.
For OpenClaw this raced the Node.js gateway's HTTP listener bind —
containerd flips status as soon as the entrypoint process spawns, but
the Express server takes a few hundred ms to start serving /readyz.
Single-shot probe → unlucky → state='errored' with
"Readiness probe failed after container reached running state".

Pre-refactor (dev branch) didn't hit this because openclaw used a
two-phase flow: `runtime.startGateway` (no probe) then
`service.waitForReady` (polled /readyz for 30s). When the new
runtime architecture folded openclaw under ManagedContainer, the
polling was lost.

Bring it into the base class: `ManagedContainer.start` now polls
`readinessProbe()` within `descriptor.readinessProbe.timeoutMs` at
`intervalMs` cadence. Deterministic probes (Hermes' `--version` exec)
succeed on the first call and exit immediately — no extra latency.
HTTP probes get the full budget they need.

Also stops misapplying `descriptor.readinessProbe` to the containerd
"Up" wait (which only takes ~50ms anyway — defaults are fine).
2026-05-11 20:41:43 +05:30
DaniAkash
b6172a4109 feat(agents): per-runtime install/start controls via RuntimesSection
The agents page only surfaced OpenClaw's lifecycle controls — Hermes
auto-installed silently at boot with no UI visibility or manual handle.
Adds a generic section that iterates over container-kind runtimes from
/runtimes and renders a control panel + status bar per adapter.

- new useRuntimes() hook hits GET /runtimes
- new RuntimesSection renders one card per container runtime, with an
  adapter-keyed extras registry for adapter-specific affordances
  (panel extras + status-bar pill / actions)
- AgentsPage replaces its hand-rolled openclaw panel + bar with the
  section, plugging Configure-provider + Terminal into the openclaw
  slot via the registry
- the section becomes adapter-agnostic: new container runtimes show up
  on the page automatically (filtered by descriptor.kind === 'container')
2026-05-11 20:15:00 +05:30
DaniAkash
9632b60425 refactor(openclaw): delete legacy UI helpers + types
The runtime state machine is now the single source of truth in the UI;
the old OpenClawStatus surface (controlPlaneStatus, lastGatewayError,
lastRecoveryReason, the status enum) and its consumers are all dead
weight after Chunks 1-4. Drop them.

UI:
- OpenClawControls.tsx: delete StatusBadge, ControlPlaneBadge,
  AgentsPageHeader, LifecycleAlert, ControlPlaneAlert, GatewayStateCards.
  Keep ProviderSelector + InlineErrorAlert — still used by the setup
  dialog and AgentsPage's inline error surface.
- agents-page-utils.ts: delete getControlPlaneCopy, getRecoveryDetail,
  getGatewayUiState, getLifecycleBanner, canManageOpenClawAgents,
  shouldShowControlPlaneDegraded, getControlPlaneCopyForStatus.
- agents-page-types.ts: delete GatewayUiState, LIFECYCLE_BANNER_COPY,
  CONTROL_PLANE_COPY, FALLBACK_CONTROL_PLANE_COPY, RECOVERY_REASON_COPY.
- useOpenClaw.ts: delete OpenClawStatus + GatewayLifecycleAction.
2026-05-11 17:39:48 +05:30
DaniAkash
fdc6b80395 refactor(openclaw): move gateway port ownership into the runtime
Port persistence + reconciliation now lives entirely on the runtime
side. Service keeps a lazy httpClient getter that always reads the
current port from runtime.getHostPort(), so a port change (via
syncState drift detection) propagates everywhere automatically.

Server:
- OpenClawContainerRuntime seeds hostPort from runtime-state.json at
  construction (readPersistedGatewayPortSync) and writes back via
  syncState when the live container's mapping drifts
- OpenClawService.hostPort, setPort, adoptRuntimeHostPort,
  ensureGatewayPortAllocated, isCurrentGatewayAvailable,
  isGatewayAvailable, isGatewayAuthenticated, isGatewayPortReady,
  the httpClient field, and the local fetchOk all deleted
- tryAutoStart is now ~10 lines: syncState → executeAction({type:start})
  → control-plane probe; no port juggling, no auth-mismatch realloc
  (that path was driving the broken-state bug from earlier)
- internal `this.hostPort` references now go through runtime.getHostPort()

Tests:
- delete the four obsolete tryAutoStart tests (each asserted internals
  that are gone) plus the unused mockGatewayAuth helpers
- add two slim tryAutoStart tests pinning the new contract
- existing runtime tests still call setHostPort, so the method survives
  as a test-only override
2026-05-11 17:37:20 +05:30
DaniAkash
4806eb414d refactor(openclaw): drop /claw/status, getStatus, and the gateway block 2026-05-11 16:59:19 +05:30
DaniAkash
7392244574 refactor(openclaw): delete duplicated service-level lifecycle methods
Removes the start/stop/restart/reconnectControlPlane/shutdown surface on
OpenClawService — these duplicated the new AgentRuntime state machine
and were the root cause of the two views disagreeing. UI flows now go
through runtime.executeAction via the RuntimeControlPanel; server
shutdown via getOpenClawRuntime().executeAction({type:'stop'}).

Server:
- delete service.start/stop/restart/reconnectControlPlane/shutdown +
  stopGatewayLogTail (now unreferenced)
- delete /claw/start /claw/stop /claw/restart /claw/reconnect routes
- replace internal `await this.restart()` (createAgent, updateProviderKeys)
  with `runtime.restartGateway` — provider-config changes only need a
  container restart, not a control-plane re-probe
- main.ts shutdown handler uses getOpenClawRuntime().executeAction directly

UI:
- useOpenClawMutations drops startOpenClaw/stopOpenClaw/restartOpenClaw/
  reconnectOpenClaw and pendingGatewayAction; setup/create/delete remain
- AgentsPage drops the legacy LifecycleAlert + ControlPlaneAlert blocks;
  the RuntimeControlPanel already renders pending state on its own
  action buttons

Tests:
- delete tests for the removed methods
- runtime mocks in restart-side tests now expose restartGateway directly
2026-05-11 16:47:20 +05:30
DaniAkash
d6440bdccd refactor(openclaw): derive legacy gateway status from runtime state
OpenClawService.getStatus was carrying its own view of "is the gateway
alive" (running/stopped/uninitialized derived from machineStatus +
isReady probe) while the new AgentRuntime maintains the canonical state
machine. The two could disagree — most visibly after a wipe + partial
restart, where the runtime correctly read not_installed but the service
still reported running/connected from in-memory fields.

Map the legacy status surface from runtime.getStatusSnapshot().state so
both pills can't contradict each other. Clear controlPlaneStatus /
lastGatewayError / lastRecoveryReason whenever the runtime isn't
running — those signals are only meaningful for an alive gateway.

First chunk of the legacy-lifecycle removal. Lifecycle methods on the
service (restart/shutdown/tryAutoStart/etc.) and duplicated hostPort
state still exist and will be removed in follow-up commits.
2026-05-11 16:32:41 +05:30
DaniAkash
349c3743a9 fix(openclaw): seed empty .env in runtime so direct Start works on a fresh install
Starting the gateway via the new RuntimeControlPanel "Start" CTA goes
through runtime.executeAction({type:'start'}) directly, bypassing
OpenClawService.tryAutoStart and its ensureStateEnvFile() seeding step.
On a freshly-wiped .browseros-dev that left nerdctl create failing with
"failed to open env file .../.openclaw/.env: no such file or directory".

Seed the file (empty, mode 0600) inside buildContainerSpec so the
runtime is self-sufficient. Service callers continue to work — their
ensureStateEnvFile is now an idempotent no-op once the file exists.
2026-05-08 23:22:57 +05:30
DaniAkash
830eebae82 fix(openclaw): stop stale gateway before re-allocating port on auth mismatch
When a previous boot leaves a gateway running with a stale token, the
realloc-on-auth-mismatch branch was bumping the persisted port without
actually freeing the old container — ManagedContainer.start() no-ops
when state==='running', so the next start cycle never recreated the
container on the new port. The result: persisted/service/runtime drift
back into mismatch, and history requests 500 with "gateway is not ready"
even while the (stale) gateway keeps serving chat from the old port.

Stop the gateway explicitly when we decide to bump off the port, so the
upcoming start cycle goes through the full remove + create + start path
on the freshly-allocated port. The token-mismatch test still passes;
adds a new test pinning the stop-before-realloc behaviour.
2026-05-08 22:28:56 +05:30
DaniAkash
4ccb7ac0fd fix(openclaw): reconcile drifted gateway host port from live container
When a previous server boot wrote runtime-state.json after the gateway
container had already been created with a different hostPort (e.g. 18789
held at allocate-time → container started on 18790), the persisted port
disagrees with the live mapping. The runtime then probes the persisted
port forever and the UI sticks at `starting`.

`syncState` now reads `NetworkSettings.Ports` from inspect-container and
adopts the actual host port for the gateway container's published port
when it differs. The service then re-syncs `hostPort`/`httpClient` and
rewrites runtime-state.json so the next boot starts from a clean slate.

- ContainerInfo gains a flat `ports` array (parsed from
  `NetworkSettings.Ports`)
- OpenClawContainerRuntime.syncState: reconcile hostPort from live
  mapping before probing /readyz
- OpenClawService.tryAutoStart: adopt the runtime's reconciled port and
  persist it via writePersistedGatewayPort
2026-05-08 21:01:10 +05:30
DaniAkash
ab63827b69 fix(openclaw): sync runtime state from existing container at boot; render Start CTA for installed state
Two stuck-state bugs in the new RuntimeControlPanel:

1. The runtime's state machine started fresh at not_installed on every
   server boot. tryAutoStart's short-circuit branches (gateway already
   running, auth pass) never drove the state transitions, so the UI
   saw not_installed for a gateway that was actually running. Add a
   syncState() method on OpenClawContainerRuntime that probes the
   actual container via cli.inspectContainer + /readyz and sets state
   accordingly. Wire it into tryAutoStart as the first step so it
   runs regardless of which branch the rest takes.

2. RuntimeControlPanel had no case for state === 'installed', so after
   a successful Install the panel went blank instead of offering the
   next step. Treat installed the same as stopped — show the Start
   CTA with copy that reflects the difference (image is pulled vs
   container exists but stopped).

Optional-chained the syncState call so existing tests with partial
runtime mocks don't crash on the missing method.
2026-05-08 19:47:27 +05:30
DaniAkash
8f68d12339 chore: merge feat/openclaw-runtime — picks up bundled-Lima fallback fix 2026-05-08 19:27:03 +05:30
DaniAkash
af16f1cc0c fix(openclaw): tolerate missing bundled Lima at runtime construction
resolveBundledLimactl / resolveBundledLimaTemplate throw synchronously
when the host has no Lima and no bundled resources — that fired during
configureOpenClawRuntime on linux CI runners, breaking server-integration.
Wrap both calls so construction falls back to the bare 'limactl' command
name (and undefined template). Lifecycle ops still fail at spawn time
on platforms without Lima, matching how Hermes/Claude/Codex degrade.
2026-05-08 19:26:31 +05:30
DaniAkash
c099a35dee refactor(ui): wire RuntimeStatusBar + RuntimeControlPanel on AgentsPage; drop legacy lifecycle UI
AgentsPage now uses the new runtime-control components for OpenClaw
lifecycle:
- RuntimeControlPanel replaces GatewayStateCards (state-appropriate
  CTAs gated on capabilities). Provider config dialog trigger lives
  in the panel's extras slot.
- RuntimeStatusBar replaces GatewayStatusBar (running pill +
  Restart). Control-plane pill + Open Terminal live in the bar's
  extra slots — gateway specifics stay outside the runtime layer.

GatewayStatusBar.tsx deletes outright. The 'Unavailable' badge in
AgentSummaryChips.tsx deletes — capabilities-driven UI surfaces the
same signal more usefully on the new RuntimeControlPanel; the prop
stays for upstream callers but is now a no-op.

ControlPlaneAlert / LifecycleAlert / InlineErrorAlert from
OpenClawControls remain — they're alerts for control-plane and
mid-flight lifecycle states, distinct from the runtime control
surface. They cover gateway-specific concerns the runtime layer
doesn't model. Cleanup deferred to a follow-up.
2026-05-08 19:20:40 +05:30
DaniAkash
8eb911d83f feat(ui): RuntimeStatusBar + RuntimeControlPanel components
RuntimeStatusBar — compact one-line bar with adapter name + state pill
+ optional Restart action. Reads from useRuntime(adapter); the pill
covers every container and host-process state. extraPill / extraActions
slots let openclaw add its control-plane pill and Open Terminal
button without baking gateway specifics into the runtime layer.

RuntimeControlPanel — capability-gated state-appropriate primary CTA:
not_installed → Install, stopped → Start, errored → Restart + Reset,
installing/starting → spinner, cli_missing/unhealthy → Reinstall CLI,
running → optional Stop. extras slot for adapter-specific affordances
(e.g. openclaw provider Setup dialog trigger).
2026-05-08 19:12:47 +05:30
DaniAkash
983e433845 feat(ui): add useRuntime / useRuntimeAction / useRuntimeLogs hooks
Generic React Query hooks backed by the typed RPC client (hc<AppType>),
keyed by adapter id. useRuntime polls /runtimes/:adapter/status every
5s by default; useRuntimeAction issues a capability-gated POST to
/runtimes/:adapter/actions/:action and invalidates the status query
on success; useRuntimeLogs is opt-in (disabled by default) for
container runtimes.
2026-05-08 19:10:25 +05:30
DaniAkash
4401e30fdc feat(server): add /runtimes/* route surface
Uniform HTTP surface backed by AgentRuntimeRegistry + runtime.executeAction:
- GET /runtimes — list all registered runtimes (descriptor + status + capabilities)
- GET /runtimes/:adapter/status — single status snapshot
- GET /runtimes/:adapter/status/stream — SSE: snapshot on connect + every state transition
- POST /runtimes/:adapter/actions/:action — capability-gated dispatch through executeAction
- GET /runtimes/:adapter/logs — container-runtime logs (405 for host-process)

Routes use zValidator for path/query/body so the typed RPC client picks
up the schemas; mounted with the same requireTrustedAppOrigin
middleware as /claw/* /terminal /acl-rules /monitoring.
2026-05-08 19:08:50 +05:30
DaniAkash
5da13e54b5 test(openclaw): make persisted-port restart test deterministic on linux CI 2026-05-08 18:56:06 +05:30
DaniAkash
5ea8cff1b6 fix(openclaw): keep runtime constructable on non-darwin so service tests + linux CI work
The previous configureOpenClawRuntime short-circuited to null on
non-darwin, which caused OpenClawService's constructor to throw
"runtime is not available on platform linux" on linux CI runners
— breaking server-api tests (which build the service then mock
service.runtime) and the server-integration test (which spawns
the server on linux). The legacy ContainerRuntime constructor was
platform-agnostic; this restores that.

The runtime now constructs on every platform. descriptor.platforms:
['darwin'] is still the live signal for the UI / adapter health,
and inherited start() fails at limactl-not-found on linux if
anyone actually invokes it. Tests that override service.runtime
post-construction (the standard pattern) work uniformly.

ensureOpenClawRuntime simplifies to a one-liner. The
configureOpenClawRuntime non-darwin test retargets to assert
the runtime is still returned (instead of asserting null).
2026-05-08 18:45:37 +05:30
DaniAkash
b494bbd41c test(runtime): cover OpenClawContainerRuntime descriptor + spec + ACP exec + factory 2026-05-08 16:54:39 +05:30
DaniAkash
f313aa532d refactor(openclaw): switch service + dispatch to OpenClawContainerRuntime; delete legacy ContainerRuntime 2026-05-08 16:49:32 +05:30
DaniAkash
a23fd55934 feat(runtime): add OpenClawContainerRuntime + factory 2026-05-08 16:04:09 +05:30
42 changed files with 2430 additions and 3501 deletions

View File

@@ -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<string | null>(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'

View File

@@ -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
}
/>

View File

@@ -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 <AgentTerminal onBack={() => 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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
@@ -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 (
<div className="min-h-full bg-background px-6 py-8">
<div className="fade-in slide-in-from-bottom-5 mx-auto flex w-full max-w-5xl animate-in flex-col gap-6 duration-500">
<AgentsHeader onCreateAgent={() => setCreateOpen(true)} />
{lifecycleBanner ? <LifecycleAlert message={lifecycleBanner} /> : null}
{inlineError ? (
<InlineErrorAlert
message={inlineError}
@@ -312,46 +279,32 @@ export const AgentsPage: FC = () => {
/>
) : null}
{status && showControlPlaneDegraded ? (
<ControlPlaneAlert
actionInProgress={actionInProgress}
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
controlPlaneCopy={controlPlaneCopy}
reconnecting={reconnecting}
recoveryDetail={recoveryDetail}
status={status}
onReconnect={() => {
void runWithPageErrorHandling(reconnectOpenClaw)
}}
onRestart={() => {
void runWithPageErrorHandling(restartOpenClaw)
}}
/>
) : null}
<GatewayStateCards
actionInProgress={actionInProgress}
status={status}
onOpenSetup={() => setSetupOpen(true)}
onRestart={() => {
void runWithPageErrorHandling(restartOpenClaw)
}}
onStart={() => {
void runWithPageErrorHandling(startOpenClaw)
<RuntimesSection
extras={{
openclaw: {
panelExtras: showSetupCta ? (
<Button
size="sm"
variant="outline"
onClick={() => setSetupOpen(true)}
>
Configure provider
</Button>
) : null,
statusBarExtraActions: (
<Button
variant="ghost"
size="sm"
onClick={() => setShowTerminal(true)}
>
<TerminalIcon className="mr-1.5 h-3.5 w-3.5" />
Terminal
</Button>
),
},
}}
/>
{showGatewayStatusBar ? (
<GatewayStatusBar
status={status}
actionInProgress={actionInProgress}
onOpenTerminal={() => setShowTerminal(true)}
onRestart={() => {
void runWithPageErrorHandling(restartOpenClaw)
}}
/>
) : null}
<AgentList
agents={agentListItems}
activity={agentActivity}
@@ -375,7 +328,6 @@ export const AgentsPage: FC = () => {
})
}}
/>
<SetupOpenClawDialog
defaultProviderId={defaultProviderId}
open={setupOpen}
@@ -387,7 +339,6 @@ export const AgentsPage: FC = () => {
onProviderChange={setSetupProviderId}
onSetup={() => void handleSetup()}
/>
<NewAgentDialog
adapters={adapters}
canManageOpenClaw={openClawManageable}

View File

@@ -1,206 +0,0 @@
import { Loader2, RotateCcw, Terminal } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { OpenClawStatus } from './useOpenClaw'
interface GatewayStatusBarProps {
status: OpenClawStatus | null
/** Disabled while a gateway lifecycle mutation is mid-flight. */
actionInProgress: boolean
onOpenTerminal: () => void
onRestart: () => void
}
/**
* Compact one-line status bar for the OpenClaw gateway. Renders the
* lifecycle pills (Running / Control plane connected) plus a Terminal
* escape hatch and a Restart Gateway action. Lives between the page
* header and the agent list when at least one OpenClaw agent is in
* the merged list; collapses to nothing for Claude/Codex-only setups.
*
* Status is sourced from `GET /agents`'s `gateway` field — the agents
* page no longer polls `/claw/status` directly. One endpoint, one
* 5s interval, no duplicate state.
*/
export const GatewayStatusBar: FC<GatewayStatusBarProps> = ({
status,
actionInProgress,
onOpenTerminal,
onRestart,
}) => {
if (!status) return null
const runningPill = pillForRuntimeStatus(status.status)
const controlPlanePill = pillForControlPlane(status.controlPlaneStatus)
return (
<div className="rounded-xl border border-border bg-card px-4 py-3 shadow-sm">
<div className="flex items-center gap-3 text-sm">
<span className="font-medium text-muted-foreground">
OpenClaw gateway
</span>
<Badge
variant={runningPill.variant}
className={cn('gap-1.5', runningPill.className)}
>
<span
className={cn(
'inline-block h-1.5 w-1.5 rounded-full',
runningPill.dot,
)}
/>
{runningPill.label}
</Badge>
<Badge
variant={controlPlanePill.variant}
className={cn('gap-1.5', controlPlanePill.className)}
>
<span
className={cn(
'inline-block h-1.5 w-1.5 rounded-full',
controlPlanePill.dot,
)}
/>
{controlPlanePill.label}
</Badge>
<Separator orientation="vertical" className="h-4" />
<WithTooltip label="Open a shell into the OpenClaw gateway container for raw CLI access (config edits, session inspection).">
<Button variant="ghost" size="sm" onClick={onOpenTerminal}>
<Terminal className="mr-1.5 h-3.5 w-3.5" />
Terminal
</Button>
</WithTooltip>
<WithTooltip label="Restart the OpenClaw gateway. Useful when the gateway is stuck or after editing provider config.">
<Button
variant="ghost"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
className="ml-auto"
>
{actionInProgress ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
)}
Restart Gateway
</Button>
</WithTooltip>
</div>
</div>
)
}
const WithTooltip: FC<{ label: string; children: ReactNode }> = ({
label,
children,
}) => (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
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',
}
}
}

View File

@@ -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 <Badge variant={current.variant}>{current.label}</Badge>
}
const ControlPlaneBadge: FC<{
status: OpenClawStatus['controlPlaneStatus']
}> = ({ status }) => {
const current = CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
return <Badge variant={current.badgeVariant}>{current.badgeLabel}</Badge>
}
interface ProviderSelectorProps {
providers: ProviderOption[]
@@ -115,112 +68,6 @@ export const ProviderSelector: FC<ProviderSelectorProps> = ({
)
}
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<AgentsPageHeaderProps> = ({
actionInProgress,
controlPlaneBusy,
reconnecting,
status,
onCreateAgent,
onOpenTerminal,
onReconnect,
onRefresh,
onRestart,
onStop,
}) => (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-semibold text-2xl tracking-normal">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw, Claude Code, and Codex agents
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{status ? (
<>
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
</>
) : null}
{status?.status === 'running' &&
status.controlPlaneStatus !== 'connected' ? (
<Button
variant="outline"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
) : null}
{status?.status === 'running' ? (
<>
<Button
variant="ghost"
size="icon"
onClick={onRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={onOpenTerminal}>
<TerminalSquare className="mr-2 size-4" />
Terminal
</Button>
</>
) : null}
<Button variant="ghost" size="icon" onClick={onRefresh} title="Refresh">
<RefreshCw className="size-4" />
</Button>
<Button onClick={onCreateAgent}>
<Plus className="mr-2 size-4" />
New Agent
</Button>
</div>
</div>
)
export function LifecycleAlert({ message }: { message: string }) {
return (
<Alert>
<Loader2 className="size-4 animate-spin" />
<AlertTitle>{message}</AlertTitle>
</Alert>
)
}
export function InlineErrorAlert({
message,
onDismiss,
@@ -243,145 +90,3 @@ export function InlineErrorAlert({
</Alert>
)
}
interface ControlPlaneAlertProps {
actionInProgress: boolean
controlPlaneBusy: boolean
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
reconnecting: boolean
recoveryDetail: string | null
status: OpenClawStatus
onReconnect: () => void
onRestart: () => void
}
export const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
actionInProgress,
controlPlaneBusy,
controlPlaneCopy,
reconnecting,
recoveryDetail,
status,
onReconnect,
onRestart,
}) => (
<Alert
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert className="size-4" />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench className="size-4" />
) : (
<WifiOff className="size-4" />
)}
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy.description}</p>
{recoveryDetail ? <p>{recoveryDetail}</p> : null}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
)
interface GatewayStateCardsProps {
actionInProgress: boolean
status: OpenClawStatus | null
onOpenSetup: () => void
onRestart: () => void
onStart: () => void
}
export const GatewayStateCards: FC<GatewayStateCardsProps> = ({
actionInProgress,
status,
onOpenSetup,
onRestart,
onStart,
}) => (
<>
{status?.status === 'uninitialized' ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
</p>
</div>
{status.podmanAvailable ? (
<Button onClick={onOpenSetup}>Set Up Now</Button>
) : null}
</CardContent>
</Card>
) : null}
{status?.status === 'stopped' ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
) : null}
{status?.status === 'error' ? (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
) : null}
</>
)

View File

@@ -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<AgentSummaryChipsProps> = ({
adapter,
modelLabel,
reasoningEffort,
adapterHealth,
}) => {
const parts = [adapterLabel(adapter)]
if (modelLabel) parts.push(modelLabel)
if (reasoningEffort) parts.push(reasoningEffort)
const unhealthy = adapterHealth?.healthy === false
return (
<div
className={cn(
'flex items-center gap-1.5 text-muted-foreground text-xs',
unhealthy && 'text-muted-foreground/70',
)}
>
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span className="truncate">{parts.join(' · ')}</span>
{unhealthy && adapterHealth && (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<Badge
variant="outline"
className="h-5 cursor-default gap-1 border-amber-500/40 bg-amber-50 px-1.5 text-amber-900 hover:bg-amber-50"
>
<TriangleAlert className="size-2.5" />
<span className="font-normal">Unavailable</span>
</Badge>
</HoverCardTrigger>
<HoverCardContent side="right" className="w-72 text-sm">
<div className="font-medium">
{adapterLabel(adapter)} CLI not available
</div>
<div className="mt-1 text-muted-foreground text-xs">
{adapterHealth.reason ??
'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'}
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)
}

View File

@@ -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<GatewayLifecycleAction, string> = {
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<OpenClawStatus['lastRecoveryReason']>,
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.',
}

View File

@@ -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[],

View File

@@ -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<RuntimeControlPanelProps> = ({
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 (
<PanelCard
title={`${data.descriptor.displayName} not installed`}
description="Pull the container image to get started. The image runs inside the bundled BrowserOS VM and stays put across restarts."
>
<Primary
icon={<Download className="mr-1.5 h-3.5 w-3.5" />}
label="Install"
onClick={() => dispatch('install')}
acting={acting}
/>
{extras}
</PanelCard>
)
if ((state === 'stopped' || state === 'installed') && caps.has('start'))
return (
<PanelCard
title={`${data.descriptor.displayName} is ${state === 'installed' ? 'ready to start' : 'stopped'}`}
description={
state === 'installed'
? 'Image is pulled. Start the container to use this adapter.'
: 'Start the container to use this adapter.'
}
>
<Primary
icon={<Play className="mr-1.5 h-3.5 w-3.5" />}
label="Start"
onClick={() => dispatch('start')}
acting={acting}
/>
{extras}
</PanelCard>
)
if (state === 'errored')
return (
<PanelCard
tone="destructive"
title={`${data.descriptor.displayName} hit an error`}
description={
data.status.lastError ??
'Restart usually clears it. Reset wipes container state.'
}
>
{caps.has('restart') && (
<Primary
icon={<RotateCcw className="mr-1.5 h-3.5 w-3.5" />}
label="Restart"
onClick={() => dispatch('restart')}
acting={acting}
/>
)}
{caps.has('reset-soft') && (
<Button
variant="outline"
size="sm"
disabled={acting}
onClick={() => dispatch('reset-soft')}
>
<TriangleAlert className="mr-1.5 h-3.5 w-3.5" />
Reset
</Button>
)}
{extras}
</PanelCard>
)
if (state === 'installing' || state === 'starting')
return (
<PanelCard
title={`${data.descriptor.displayName} is ${state === 'installing' ? 'installing' : 'starting'}`}
description="This usually takes a few seconds."
>
<Button variant="ghost" size="sm" disabled>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Working
</Button>
{extras}
</PanelCard>
)
// Host-process runtime states.
if (state === 'cli_missing' && caps.has('reinstall-cli'))
return (
<PanelCard
tone="muted"
title={`${data.descriptor.displayName} CLI not installed`}
description="Install the CLI on your $PATH to use this adapter."
>
<Primary
icon={<Download className="mr-1.5 h-3.5 w-3.5" />}
label="Reinstall CLI"
onClick={() => dispatch('reinstall-cli')}
acting={acting}
/>
{extras}
</PanelCard>
)
if (state === 'cli_unhealthy' && caps.has('reinstall-cli'))
return (
<PanelCard
tone="destructive"
title={`${data.descriptor.displayName} CLI is unhealthy`}
description={data.status.lastError ?? 'Reinstall to recover.'}
>
<Primary
icon={<Download className="mr-1.5 h-3.5 w-3.5" />}
label="Reinstall CLI"
onClick={() => dispatch('reinstall-cli')}
acting={acting}
/>
{extras}
</PanelCard>
)
// 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 ? (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
disabled={acting}
onClick={() => dispatch('stop')}
>
<Square className="mr-1.5 h-3.5 w-3.5" />
Stop
</Button>
{extras}
</div>
) : null
return null
}
interface PrimaryProps {
icon: ReactNode
label: string
onClick: () => void
acting: boolean
}
const Primary: FC<PrimaryProps> = ({ icon, label, onClick, acting }) => (
<Button onClick={onClick} disabled={acting} size="sm">
{acting ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : icon}
{label}
</Button>
)
interface PanelCardProps {
title: string
description?: string
tone?: 'default' | 'destructive' | 'muted'
children: ReactNode
}
const PanelCard: FC<PanelCardProps> = ({
title,
description,
tone = 'default',
children,
}) => (
<Card
className={
tone === 'destructive'
? 'border-destructive/40 bg-destructive/5'
: tone === 'muted'
? 'bg-muted/30'
: undefined
}
>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{title}</CardTitle>
{description && (
<CardDescription className="text-xs">{description}</CardDescription>
)}
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 pt-0">
{children}
</CardContent>
</Card>
)

View File

@@ -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<RuntimeStatusBarProps> = ({
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 (
<div className="rounded-xl border border-border bg-card px-4 py-3 shadow-sm">
<div className="flex items-center gap-3 text-sm">
<span className="font-medium text-muted-foreground">
{data.descriptor.displayName}
</span>
<Badge variant={pill.variant} className={cn('gap-1.5', pill.className)}>
<span
className={cn('inline-block h-1.5 w-1.5 rounded-full', pill.dot)}
/>
{pill.label}
</Badge>
{extraPill}
{(canRestart || extraActions) && (
<Separator orientation="vertical" className="h-4" />
)}
{extraActions}
{canRestart && (
<WithTooltip label={`Restart ${data.descriptor.displayName}.`}>
<Button
variant="ghost"
size="sm"
disabled={acting}
onClick={() => restart.mutate({ action: 'restart' })}
className="ml-auto"
>
{acting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
)}
Restart
</Button>
</WithTooltip>
)}
</div>
{data.status.lastError && data.status.state === 'errored' && (
<p className="mt-2 text-destructive text-xs">{data.status.lastError}</p>
)}
</div>
)
}
const WithTooltip: FC<{ label: string; children: ReactNode }> = ({
label,
children,
}) => (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
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',
}
}
}

View File

@@ -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<Record<RuntimeAdapterId, RuntimeAdapterExtras>>
}
/** 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<RuntimesSectionProps> = ({ 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 (
<div className="flex flex-col gap-3">
{containerRuntimes.map((runtime) => (
<RuntimeCard
key={runtime.descriptor.adapterId}
runtime={runtime}
extras={extras?.[runtime.descriptor.adapterId as RuntimeAdapterId]}
/>
))}
</div>
)
}
interface RuntimeCardProps {
runtime: RuntimeView
extras?: RuntimeAdapterExtras
}
const RuntimeCard: FC<RuntimeCardProps> = ({ runtime, extras }) => {
const adapter = runtime.descriptor.adapterId as RuntimeAdapterId
const showStatusBar = runtime.status.state === 'running'
return (
<div className="flex flex-col gap-3">
<RuntimeControlPanel adapter={adapter} extras={extras?.panelExtras} />
{showStatusBar && (
<RuntimeStatusBar
adapter={adapter}
extraPill={extras?.statusBarExtraPill}
extraActions={extras?.statusBarExtraActions}
/>
)}
</div>
)
}

View File

@@ -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,

View File

@@ -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<T>(
baseUrl: string,
path: string,
@@ -92,10 +59,6 @@ async function clawFetch<T>(
return res.json() as Promise<T>
}
async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
return clawFetch<OpenClawStatus>(baseUrl, '/status')
}
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
return (data.agents ?? []).map((agent) => ({
@@ -107,32 +70,9 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
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<OpenClawStatus, Error>({
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,
}
}

View File

@@ -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<string, unknown>
}
export interface RuntimeView {
descriptor: {
adapterId: string
displayName: string
kind: RuntimeKind
platforms: ReadonlyArray<string>
}
status: RuntimeStatusSnapshot
capabilities: ReadonlyArray<string>
}
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<RuntimeView[], Error>({
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<RuntimeView, Error>({
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,
})
}

View File

@@ -14,7 +14,6 @@ import { stream } from 'hono/streaming'
import { formatUserMessage } from '../../agent/format-message'
import type { Browser } from '../../browser/browser'
import { createAcpUIMessageStreamResponse } from '../../lib/agents/acp-ui-message-stream'
import type { OpenclawGatewayAccessor } from '../../lib/agents/acpx-runtime'
import type {
ActiveTurnInfo,
TurnFrame,
@@ -34,7 +33,6 @@ import type { AgentHistoryPage, AgentStreamEvent } from '../../lib/agents/types'
import {
type AgentDefinitionWithActivity,
AgentHarnessService,
type GatewayStatusSnapshot,
HermesProviderConfigInvalidError,
InvalidAgentUpdateError,
MessageQueueFullError,
@@ -53,7 +51,6 @@ import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-p
type AgentRouteService = {
listAgents(): Promise<AgentDefinition[]>
listAgentsWithActivity(): Promise<AgentDefinitionWithActivity[]>
getGatewayStatus(): Promise<GatewayStatusSnapshot | null>
createAgent(input: {
name: string
adapter: AgentAdapter
@@ -121,12 +118,6 @@ type AgentRouteDeps = {
service?: AgentRouteService
browser?: Pick<Browser, 'resolveTabIds'>
browserosServerPort?: number
/**
* Required when an `openclaw` adapter agent is in use; harmless when
* absent. Forwarded to the AcpxRuntime so it can spawn `openclaw acp`
* inside the gateway container.
*/
openclawGateway?: OpenclawGatewayAccessor
/**
* Optional. Enables the image-attachment carve-out for OpenClaw
* Required to dual-create/delete `openclaw` adapter agents on the
@@ -159,7 +150,6 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
deps.service ??
new AgentHarnessService({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
openclawProvisioner: deps.openclawProvisioner,
})
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
@@ -181,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)

View File

@@ -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()

View File

@@ -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<RuntimeAction['type']>
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<void>((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)
}
},
)
}

View File

@@ -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<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createRuntimeRoutes())
const terminalRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route(
@@ -136,12 +141,6 @@ export async function createHttpServer(config: HttpServerConfig) {
createAgentRoutes({
browserosServerPort: port,
browser,
openclawGateway: {
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
getLimaHomeDir: () => getLimaHomeDir(),
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
getVmName: () => VM_NAME,
},
openclawProvisioner: {
createAgent: (input) => getOpenClawService().createAgent(input),
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
@@ -153,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
@@ -243,6 +241,7 @@ export async function createHttpServer(config: HttpServerConfig) {
)
.route('/agents', agentRoutes)
.route('/claw', clawRoutes)
.route('/runtimes', runtimeRoutes)
// Error handler
app.onError((err, c) => {

View File

@@ -4,10 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
AcpxRuntime,
type OpenclawGatewayAccessor,
} from '../../../lib/agents/acpx-runtime'
import { AcpxRuntime } from '../../../lib/agents/acpx-runtime'
import {
type ActiveTurnInfo,
type TurnFrame,
@@ -125,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<GatewayStatusSnapshot | null>
/**
* Optional. When wired, the harness uses this for `getHistory` on
* openclaw-adapter agents so the chat panel sees autonomous
@@ -232,7 +220,6 @@ export class AgentHarnessService {
runtime?: AgentRuntime
browserosServerPort?: number
browserosDir?: string
openclawGateway?: OpenclawGatewayAccessor
openclawProvisioner?: OpenClawProvisioner
turnRegistry?: TurnRegistry
messageQueue?: FileMessageQueue
@@ -244,7 +231,6 @@ export class AgentHarnessService {
deps.runtime ??
new AcpxRuntime({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
})
this.openclawProvisioner = deps.openclawProvisioner ?? null
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
@@ -316,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<GatewayStatusSnapshot | null> {
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

View File

@@ -1,195 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../../../lib/browseros-dir'
import { ContainerCli, ImageLoader } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../../lib/vm'
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
import { ContainerRuntime } from './container-runtime'
const UNSUPPORTED_PLATFORM_MESSAGE =
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
export interface ContainerRuntimeFactoryInput {
resourcesDir?: string
projectDir: string
browserosRoot?: string
platform?: NodeJS.Platform
}
export function buildContainerRuntime(
input: ContainerRuntimeFactoryInput,
): ContainerRuntime {
const platform = input.platform ?? process.platform
if (platform !== 'darwin') {
// BROWSEROS_SKIP_OPENCLAW=1 is the explicit opt-in for non-darwin hosts
// (e.g. Linux CI runners) where OpenClaw can't actually run but the rest
// of the server should still come up. Returns a no-op runtime — any
// OpenClaw API call hitting it will fail loudly at request time.
if (
process.env.NODE_ENV === 'test' ||
process.env.BROWSEROS_SKIP_OPENCLAW === '1'
) {
return new UnsupportedPlatformTestRuntime(input.projectDir)
}
throw unsupportedPlatformError()
}
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
if (input.resourcesDir) {
migrateLegacyOpenClawDirSync(browserosRoot)
}
const limactlPath = input.resourcesDir
? resolveBundledLimactl(input.resourcesDir)
: 'limactl'
const limaHome = getLimaHomeDir(browserosRoot)
const vm = new VmRuntime({
limactlPath,
limaHome,
templatePath: input.resourcesDir
? resolveBundledLimaTemplate(input.resourcesDir)
: undefined,
browserosRoot,
})
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new ImageLoader(shell)
return new ContainerRuntime({
vm,
shell,
loader,
projectDir: input.projectDir,
})
}
export async function migrateLegacyOpenClawDir(
browserosRoot = getBrowserosDir(),
): Promise<void> {
migrateLegacyOpenClawDirSync(browserosRoot)
}
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
const legacyDir = join(browserosRoot, 'openclaw')
const nextDir = join(browserosRoot, 'vm', 'openclaw')
if (!existsSync(legacyDir)) return
if (existsSync(nextDir)) {
logger.warn('OpenClaw legacy and VM state directories both exist', {
legacyDir,
nextDir,
})
return
}
mkdirSync(dirname(nextDir), { recursive: true })
cpSync(legacyDir, nextDir, { recursive: true })
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
from: legacyDir,
to: nextDir,
})
}
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
constructor(projectDir: string) {
super({
vm: {} as VmRuntime,
shell: {} as ContainerCli,
loader: {
ensureImageLoaded: rejectUnsupportedPlatform,
ensureAgentImageLoaded: rejectUnsupportedPlatform,
},
projectDir,
})
}
override async ensureReady(): Promise<void> {
throw unsupportedPlatformError()
}
override async isPodmanAvailable(): Promise<boolean> {
return false
}
override async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return { initialized: false, running: false }
}
override async pullImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async prewarmGatewayImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async isGatewayCurrent(): Promise<boolean> {
return false
}
override async startGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async stopGateway(): Promise<void> {}
override async restartGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async getGatewayLogs(): Promise<string[]> {
return []
}
override async isHealthy(): Promise<boolean> {
return false
}
override async isReady(): Promise<boolean> {
return false
}
override async waitForReady(): Promise<boolean> {
return false
}
override async stopVm(): Promise<void> {}
override async execInContainer(): Promise<number> {
throw unsupportedPlatformError()
}
override async runInContainer(): Promise<never> {
throw unsupportedPlatformError()
}
override async runGatewaySetupCommand(): Promise<number> {
throw unsupportedPlatformError()
}
override tailGatewayLogs(): () => void {
return () => {}
}
}
async function rejectUnsupportedPlatform(): Promise<never> {
throw unsupportedPlatformError()
}
function unsupportedPlatformError(): Error {
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
}

View File

@@ -1,436 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
OPENCLAW_AGENT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import type {
ContainerCli,
ContainerCommandResult,
ContainerSpec,
LogFn,
WaitForContainerNameReleaseOptions,
} from '../../../lib/container'
import { isContainerNameInUse } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
GUEST_VM_STATE,
hostPathToGuest,
type VmRuntime,
} from '../../../lib/vm'
import { ContainerNameInUseError } from '../../../lib/vm/errors'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
const OPENCLAW_NAME_RELEASE_WAIT: WaitForContainerNameReleaseOptions = {
timeoutMs: 10_000,
intervalMs: 100,
}
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
// are installed via npm into the mounted home are discoverable by
// OpenClaw's child-process spawns (no login shell is involved).
const GATEWAY_PATH = [
`${GATEWAY_NPM_PREFIX}/bin`,
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin',
].join(':')
export type GatewayContainerSpec = {
hostPort: number
hostHome: string
envFilePath: string
timezone: string
}
export interface ContainerRuntimeConfig {
vm: VmRuntime
shell: ContainerCli
loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
}
projectDir: string
}
export class ContainerRuntime {
private readonly vm: VmRuntime
private readonly shell: ContainerCli
private readonly loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
}
private readonly projectDir: string
constructor(config: ContainerRuntimeConfig) {
this.vm = config.vm
this.shell = config.shell
this.loader = config.loader
this.projectDir = config.projectDir
}
async ensureReady(onLog?: LogFn): Promise<void> {
logger.info('Ensuring BrowserOS VM runtime readiness')
await this.vm.ensureReady(onLog)
await this.vm.getDefaultGateway()
}
async isPodmanAvailable(): Promise<boolean> {
return true
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
const running = await this.vm.isReady()
return { initialized: running, running }
}
async pullImage(image: string, onLog?: LogFn): Promise<void> {
await this.loader.ensureImageLoaded(image, onLog)
}
/** Warm the gateway image in containerd without creating or starting containers. */
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
await this.ensureGatewayImageLoaded(onLog)
}
/** Report whether the existing gateway container was created from the target image. */
async isGatewayCurrent(): Promise<boolean> {
const image = await this.shell.containerImageRef(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
const expected = this.expectedGatewayImageRef()
const current = imageMatchesExpectedRef(image, expected)
if (!current) {
logger.info('OpenClaw gateway image is not current', {
actualImageRef: image,
expectedImageRef: expected,
})
}
return current
}
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
const image = await this.ensureGatewayImageLoaded(onLog)
const container = await this.buildGatewayContainerSpec(input, image)
await this.createContainerWithNameReconcile(container, onLog)
await this.shell.startContainer(container.name)
}
async stopGateway(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
}
async restartGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.startGateway(input, onLog)
}
async getGatewayLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.shell.runCommand(
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
(line) => lines.push(line),
)
return lines
}
async isHealthy(hostPort: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(hostPort: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(hostPort)) return true
await Bun.sleep(1000)
}
logger.error('Timed out waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
return false
}
async stopVm(): Promise<void> {
await this.vm.stopVm()
}
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
}
// Unlike execInContainer, this returns stdout and stderr separately
// so callers that need to parse program output (e.g. JSON status
// commands) aren't forced to untangle it from nerdctl's stderr.
async runInContainer(command: string[]): Promise<ContainerCommandResult> {
return this.shell.runCommand([
'exec',
OPENCLAW_GATEWAY_CONTAINER_NAME,
...command,
])
}
async runGatewaySetupCommand(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.removeContainerAndWait(setupContainerName, onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createResult = await this.runSetupCreateWithNameReconcile(
setupContainerName,
[
'create',
'--name',
setupContainerName,
...(await this.buildGatewayRunArgs(spec)),
image,
'node',
...setupArgs,
],
onLog,
)
if (createResult.exitCode !== 0) {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
return createResult.exitCode
}
try {
const startResult = await this.shell.runCommand(
['start', '-a', setupContainerName],
onLog,
)
return startResult.exitCode
} finally {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
}
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
await this.removeContainerAndWait(OPENCLAW_GATEWAY_CONTAINER_NAME, onLog)
}
/** Create the fixed-name gateway after reconciling stale nerdctl name ownership. */
private async createContainerWithNameReconcile(
container: ContainerSpec,
onLog?: LogFn,
): Promise<void> {
let attempt = 1
while (true) {
await this.removeContainerAndWait(container.name, onLog)
try {
await this.shell.createContainer(container, onLog)
return
} catch (err) {
if (
!(err instanceof ContainerNameInUseError) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
throw err
}
logger.warn('OpenClaw container name still in use; retrying create', {
containerName: container.name,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
})
attempt++
}
}
}
private async runSetupCreateWithNameReconcile(
setupContainerName: string,
createArgs: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
let attempt = 1
while (true) {
const result = await this.shell.runCommand(createArgs, onLog)
if (
result.exitCode === 0 ||
!isContainerNameInUse(result.stderr) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
return result
}
logger.warn(
'OpenClaw setup container name still in use; retrying create',
{
containerName: setupContainerName,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
},
)
await this.removeContainerAndWait(setupContainerName, onLog)
attempt++
}
}
private async removeContainerAndWait(
containerName: string,
onLog?: LogFn,
): Promise<void> {
await this.shell.removeContainer(containerName, { force: true }, onLog)
await this.shell.waitForContainerNameRelease(
containerName,
OPENCLAW_NAME_RELEASE_WAIT,
)
}
private async buildGatewayContainerSpec(
input: GatewayContainerSpec,
image: string,
): Promise<ContainerSpec> {
return {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: input.hostPort,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
],
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
env: this.buildGatewayEnv(input),
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
addHosts: [await this.hostContainersInternalEntry()],
health: {
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
interval: '30s',
timeout: '10s',
retries: 3,
},
command: [
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
'--allow-unconfigured',
],
}
}
private async buildGatewayRunArgs(
input: GatewayContainerSpec,
): Promise<string[]> {
const args = [
'--env-file',
this.translateHostPath(input.envFilePath, input.hostHome),
'-v',
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
]
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
args.push('-e', `${key}=${value}`)
}
args.push('--add-host', await this.hostContainersInternalEntry())
return args
}
private async hostContainersInternalEntry(): Promise<string> {
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
}
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
const override = process.env.OPENCLAW_IMAGE?.trim()
if (override) {
await this.loader.ensureImageLoaded(override, onLog)
return override
}
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
}
private expectedGatewayImageRef(): string {
return process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
OPENCLAW_NO_RESPAWN: '1',
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
NODE_ENV: 'production',
TZ: input.timezone,
PATH: GATEWAY_PATH,
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}
}
private translateHostPath(path: string, openclawHostDir: string): string {
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
if (path.startsWith(`${openclawHostDir}/`)) {
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
}
return hostPathToGuest(path)
}
}
function imageMatchesExpectedRef(
actual: string | null,
expected: string,
): boolean {
return (
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
)
}

View File

@@ -17,6 +17,11 @@ import {
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import {
configureOpenClawRuntime,
getOpenClawRuntime,
type OpenClawContainerRuntime,
} from '../../../lib/agents/runtime'
import type { AgentStreamEvent } from '../../../lib/agents/types'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
@@ -26,11 +31,6 @@ import {
type AgentSessionState,
ClawSession,
} from './claw-session'
import type {
ContainerRuntime,
GatewayContainerSpec,
} from './container-runtime'
import { buildContainerRuntime } from './container-runtime-factory'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -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 {
@@ -354,66 +316,59 @@ export interface DashboardResponse {
}
export class OpenClawService {
private runtime: ContainerRuntime
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<void> = Promise.resolve()
private clawSession = new ClawSession()
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
this.runtime = buildContainerRuntime({
this.runtime = ensureOpenClawRuntime({
resourcesDir: config.resourcesDir,
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
browserosDir: config.browserosDir,
})
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
}
let runtimeChanged = false
if (
config.resourcesDir !== undefined &&
config.resourcesDir !== this.resourcesDir
) {
this.resourcesDir = config.resourcesDir
runtimeChanged = true
}
if (
config.browserosDir !== undefined &&
config.browserosDir !== this.browserosDir
) {
this.browserosDir = config.browserosDir
runtimeChanged = true
}
if (runtimeChanged) {
this.rebuildRuntimeClients()
}
}
getPort(): number {
return this.hostPort
return this.runtime.getHostPort()
}
/** Subscribe to real-time agent status changes from the ClawSession state machine. */
@@ -516,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,
@@ -538,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,
@@ -562,14 +515,11 @@ export class OpenClawService {
await this.assertConfigValid(this.bootstrapCliClient)
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
await this.runtime.startGateway(undefined, logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
this.runtime.getHostPort(),
READY_TIMEOUT_MS,
)
if (!ready) {
@@ -579,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())
@@ -604,202 +553,14 @@ export class OpenClawService {
this.lastError = null
logProgress(
`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`,
`OpenClaw gateway running at http://127.0.0.1:${this.runtime.getHostPort()}`,
)
logger.info('OpenClaw setup complete', { hostPort: this.hostPort })
})
}
async start(onLog?: (msg: string) => void): Promise<void> {
return this.withLifecycleLock('start', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Starting OpenClaw service', {
hostPort: this.hostPort,
logger.info('OpenClaw setup complete', {
hostPort: this.runtime.getHostPort(),
})
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(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after start'
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
await this.ensureAllCliProvidersInstalled(logProgress)
this.lastError = null
logger.info('OpenClaw gateway started', { hostPort: this.hostPort })
})
}
async stop(): Promise<void> {
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<void> {
return this.withLifecycleLock('restart', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Restarting OpenClaw service', {
hostPort: this.hostPort,
})
this.controlPlaneStatus = 'reconnecting'
await this.runtime.ensureReady(logProgress)
this.stopGatewayLogTail()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
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<void> {
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(this.hostPort)
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<void> {
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<OpenClawStatusResponse> {
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(this.hostPort)
: 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: {
@@ -837,7 +598,7 @@ export class OpenClawService {
configChanged,
keysChanged,
})
await this.restart()
await this.runtime.restartGateway(undefined)
}
const model = provider.model
@@ -1101,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
@@ -1140,37 +901,22 @@ export class OpenClawService {
async tryAutoStart(): Promise<void> {
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 (this.runtime.getStatusSnapshot().state !== 'running') {
await this.runtime.executeAction({ type: 'start' })
}
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
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
}
}
await this.runControlPlaneCall(() => this.cliClient.probe())
await this.cliClient.probe()
await this.ensureAllCliProvidersInstalled()
logger.info('OpenClaw gateway auto-started')
} catch (err) {
@@ -1257,129 +1003,17 @@ export class OpenClawService {
private buildBootstrapCliClient(): OpenClawCliClient {
return new OpenClawCliClient({
execInContainer: (command, onLog) =>
this.runtime.runGatewaySetupCommand(
command,
this.buildGatewayRuntimeSpec(),
onLog,
),
this.runtime.runGatewaySetupCommand(command, undefined, onLog),
})
}
private rebuildRuntimeClients(): void {
this.stopGatewayLogTail()
this.runtime = buildContainerRuntime({
resourcesDir: this.resourcesDir ?? undefined,
projectDir: this.openclawDir,
browserosRoot: this.browserosDir,
})
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
}
private setPort(hostPort: number): void {
if (hostPort === this.hostPort) return
this.hostPort = hostPort
this.httpClient = new OpenClawHttpClient(this.hostPort)
}
private async ensureGatewayPortAllocated(
logProgress?: (msg: string) => void,
): Promise<void> {
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<boolean> {
if (!(await this.isGatewayPortReady(hostPort))) return false
return this.isGatewayAuthenticated(hostPort)
}
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
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<boolean> {
if (!(await this.isGatewayAvailable(hostPort))) return false
return this.runtime.isGatewayCurrent()
}
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
if (await this.runtime.isReady(hostPort)) return true
const runtime = this.runtime as {
isHealthy?: (port: number) => Promise<boolean>
}
if (runtime.isHealthy) {
return runtime.isHealthy(hostPort)
}
return false
}
private async assertGatewayReady(): Promise<void> {
const portReady = await this.runtime.isReady(this.hostPort)
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<T>(fn: () => Promise<T>): Promise<T> {
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 {
@@ -1397,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)
}
@@ -1448,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()}`,
],
},
{
@@ -1570,7 +1194,7 @@ export class OpenClawService {
private async waitForGatewayAfterCliMutation(): Promise<void> {
const ready = await this.runtime.waitForReady(
this.hostPort,
this.runtime.getHostPort(),
READY_TIMEOUT_MS,
)
if (!ready) {
@@ -1600,15 +1224,6 @@ export class OpenClawService {
await writeFile(envPath, '', { mode: 0o600 })
}
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
return {
hostPort: this.hostPort,
hostHome: this.openclawDir,
envFilePath: this.getStateEnvPath(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
private async writeStateEnv(
values: Record<string, string>,
): Promise<boolean> {
@@ -1768,3 +1383,14 @@ export function getOpenClawService(): OpenClawService {
if (!service) service = new OpenClawService()
return service
}
/** 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
* platform; lifecycle calls fail at limactl-not-found on non-darwin. */
function ensureOpenClawRuntime(opts: {
resourcesDir?: string
browserosDir?: string
}): OpenClawContainerRuntime {
return getOpenClawRuntime() ?? configureOpenClawRuntime(opts)
}

View File

@@ -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<RuntimeState>
if (
typeof parsed.gatewayPort === 'number' &&
Number.isInteger(parsed.gatewayPort) &&
parsed.gatewayPort > 0 &&
parsed.gatewayPort <= MAX_TCP_PORT
) {
return parsed.gatewayPort
}
return null
return validateGatewayPort(parsed)
} catch {
return null
}
}
async function writePersistedGatewayPort(
/** 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<RuntimeState>
return validateGatewayPort(parsed)
} catch {
return null
}
}
function validateGatewayPort(parsed: Partial<RuntimeState>): 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<void> {

View File

@@ -5,11 +5,11 @@
*/
import type { AgentDefinition } from './agent-types'
import { prepareOpenClawContext } from './openclaw/prepare'
import {
prepareClaudeCodeContext,
prepareCodexContext,
prepareHermesContext,
prepareOpenClawContext,
} from './runtime'
export interface PreparedAcpxAgentContext {

View File

@@ -5,7 +5,6 @@
*/
import { join } from 'node:path'
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import {
type AcpRuntimeEvent,
@@ -32,7 +31,7 @@ import type {
AgentHistoryEntry,
AgentHistoryToolCall,
} from './agent-types'
import { getHermesRuntime } from './runtime'
import { getHermesRuntime, getOpenClawRuntime } from './runtime'
import type {
AgentHistoryPage,
AgentPromptInput,
@@ -43,35 +42,11 @@ import type {
AgentStreamEvent,
} from './types'
/**
* Live-getter access to the OpenClaw gateway runtime info. Required
* when spawning the openclaw ACP adapter inside the gateway container.
*
* Fields are getters (not snapshot values) so the harness picks up the
* current VM/container paths at spawn time. The bundled gateway runs
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
*/
export interface OpenclawGatewayAccessor {
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
getLimaHomeDir(): string
/** Resolved path to the `limactl` binary (bundled or host). */
getLimactlPath(): string
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
getVmName(): string
}
type AcpxRuntimeOptions = {
cwd?: string
browserosDir?: string
stateDir?: string
browserosServerPort?: number
/**
* Required for adapter='openclaw' agents; harmless when absent for
* claude/codex (their adapters spawn their own CLI binaries).
*/
openclawGateway?: OpenclawGatewayAccessor
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
}
@@ -91,7 +66,6 @@ export class AcpxRuntime implements AgentRuntime {
private readonly browserosDir: string
private readonly stateDir: string
private readonly browserosServerPort: number
private readonly openclawGateway: OpenclawGatewayAccessor | null
private readonly runtimeFactory: (
options: AcpRuntimeOptions,
) => AcpxCoreRuntime
@@ -107,7 +81,6 @@ export class AcpxRuntime implements AgentRuntime {
join(this.browserosDir, 'agents', 'acpx')
this.browserosServerPort =
options.browserosServerPort ?? DEFAULT_PORTS.server
this.openclawGateway = options.openclawGateway ?? null
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
}
@@ -272,7 +245,6 @@ export class AcpxRuntime implements AgentRuntime {
cwd: input.cwd,
sessionStore: this.sessionStore,
agentRegistry: createBrowserosAgentRegistry({
openclawGateway: this.openclawGateway,
openclawSessionKey: input.openclawSessionKey,
commandEnv: input.commandEnv,
}),
@@ -683,7 +655,6 @@ function createBrowserosMcpServers(
}
function createBrowserosAgentRegistry(input: {
openclawGateway: OpenclawGatewayAccessor | null
openclawSessionKey: string | null
commandEnv: Record<string, string>
}): AcpRuntimeOptions['agentRegistry'] {
@@ -697,18 +668,20 @@ function createBrowserosAgentRegistry(input: {
const lower = agentName.trim().toLowerCase()
if (lower === 'openclaw') {
if (!input.openclawGateway) {
// Fall back to acpx's built-in `openclaw` adapter, which assumes
// a host-side openclaw binary. BrowserOS doesn't install one on
// the host, so this branch will fail at spawn time with a
// descriptive error — the harness should be wired with a
// gateway accessor.
return registry.resolve(agentName)
const runtime = getOpenClawRuntime()
if (runtime) {
return runtime.buildExecArgv(
runtime.getAcpExecSpec({
commandEnv: input.commandEnv,
openclawSessionKey: input.openclawSessionKey,
}),
)
}
return resolveOpenclawAcpCommand(
input.openclawGateway,
input.openclawSessionKey,
)
// Tests / non-darwin: fall back to acpx-core's built-in
// `openclaw` adapter, which assumes a host-side openclaw
// binary. BrowserOS doesn't install one on the host, so this
// branch fails at spawn time with a descriptive error.
return registry.resolve(agentName)
}
if (lower === 'hermes') {
@@ -736,79 +709,6 @@ function createBrowserosAgentRegistry(input: {
}
}
/**
* Builds the command string acpx will spawn for an `openclaw` adapter.
* Runs `openclaw acp` inside the gateway container via the bundled
* `limactl shell <vm> -- nerdctl exec -i ...` chain so the binary
* already installed alongside the gateway is reused; BrowserOS does
* not require a host-side openclaw install.
*
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
* so no gateway token flag is needed for the local ACP bridge.
*
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
* the ACP message stream.
*/
function resolveOpenclawAcpCommand(
gateway: OpenclawGatewayAccessor,
sessionKey: string | null,
): string {
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
const limaHome = gateway.getLimaHomeDir()
const gatewayUrlInsideContainer = `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`
// `--session <key>` routes the bridge's newSession requests to the
// matching gateway agent. acpx does not pass sessionKey through ACP
// newSession params, so without this CLI flag the bridge falls back
// to a synthetic acp:<uuid> session that does not resolve to any
// provisioned gateway agent.
//
// Harness keys are `agent:<harness-id>:main`; the harness id matches
// a dual-created gateway agent name, so the bridge resolves directly.
// Any legacy non-agent key falls back to the always-provisioned
// `main` gateway agent with the original key encoded as a channel
// suffix.
const bridgeSessionKey = sessionKey
? sessionKey.startsWith('agent:')
? sessionKey
: `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
: null
//
// Prefix `env LIMA_HOME=<path>` so the spawned limactl finds the
// BrowserOS-owned VM instance. The BrowserOS server doesn't set
// LIMA_HOME on its own process env (it injects per-spawn elsewhere),
// so the acpx-spawned subprocess won't inherit it without this hint.
const argv = [
'env',
`LIMA_HOME=${limaHome}`,
limactl,
'shell',
'--workdir',
'/',
vm,
'--',
'nerdctl',
'exec',
'-i',
'-e',
'OPENCLAW_HIDE_BANNER=1',
'-e',
'OPENCLAW_SUPPRESS_NOTES=1',
container,
'openclaw',
'acp',
'--url',
gatewayUrlInsideContainer,
]
if (bridgeSessionKey) {
argv.push('--session', bridgeSessionKey)
}
return argv.join(' ')
}
async function applyRuntimeControls(
runtime: AcpxCoreRuntime,
handle: AcpRuntimeHandle,

View File

@@ -1,44 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type {
PrepareAcpxAgentContextInput,
PreparedAcpxAgentContext,
} from '../acpx-agent-adapter'
import {
buildBrowserosAcpPrompt,
ensureUsableCwd,
resolveAgentRuntimePaths,
} from '../acpx-runtime-context'
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
/**
* Prepares OpenClaw without BrowserOS SOUL/MEMORY or BrowserOS MCP.
* OpenClaw runs inside the gateway VM/container, so a selected host cwd is not visible there.
*/
export async function prepareOpenClawContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
const paths = resolveAgentRuntimePaths({
browserosDir: input.browserosDir,
agentId: input.agent.id,
})
await ensureUsableCwd(paths.effectiveCwd, true)
return {
cwd: paths.effectiveCwd,
runtimeSessionKey: input.sessionKey,
runPrompt: buildBrowserosAcpPrompt(
OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS,
input.message,
),
commandEnv: {},
commandIdentity: 'openclaw',
useBrowserosMcp: false,
openclawSessionKey: input.sessionKey,
}
}

View File

@@ -35,6 +35,14 @@ export {
HostProcessAgentRuntime,
type HostProcessAgentRuntimeDeps,
} from './host-process-agent-runtime'
export {
type ConfigureOpenClawRuntimeOptions,
configureOpenClawRuntime,
getOpenClawRuntime,
OpenClawContainerRuntime,
type OpenClawContainerRuntimeConfig,
prepareOpenClawContext,
} from './openclaw-container-runtime'
export {
AgentRuntimeRegistry,
getAgentRuntimeRegistry,

View File

@@ -0,0 +1,526 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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'
import type {
ContainerDescriptor,
ManagedContainerDeps,
MountRoot,
} from '../../container/managed'
import type { ContainerSpec, LogFn } from '../../container/types'
import { logger } from '../../logger'
import {
GUEST_VM_STATE,
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../vm'
import type {
PrepareAcpxAgentContextInput,
PreparedAcpxAgentContext,
} from '../acpx-agent-adapter'
import {
buildBrowserosAcpPrompt,
ensureUsableCwd,
resolveAgentRuntimePaths,
} from '../acpx-runtime-context'
import { ContainerAgentRuntime } from './container-agent-runtime'
import { getAgentRuntimeRegistry } from './registry'
import type { ExecSpec } from './types'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
const GATEWAY_PATH = [
`${GATEWAY_NPM_PREFIX}/bin`,
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin',
].join(':')
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
export interface OpenClawContainerRuntimeConfig {
/** BrowserOS state root. */
browserosDir: string
/** OpenClaw state dir (`<browserosDir>/vm/openclaw`). */
openclawDir: string
}
export class OpenClawContainerRuntime extends ContainerAgentRuntime {
readonly descriptor: ContainerDescriptor & { kind: 'container' } = {
adapterId: 'openclaw',
displayName: 'OpenClaw',
kind: 'container',
defaultImage: process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE,
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
platforms: ['darwin'],
readinessProbe: { timeoutMs: 60_000, intervalMs: 1_000 },
}
private readonly openclawConfig: OpenClawContainerRuntimeConfig
private hostPort: number = OPENCLAW_GATEWAY_CONTAINER_PORT
constructor(
deps: ManagedContainerDeps,
config: OpenClawContainerRuntimeConfig,
) {
super(deps)
this.openclawConfig = config
// 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[] {
return [
{
hostPath: this.openclawConfig.openclawDir,
containerPath: GATEWAY_CONTAINER_HOME,
kind: 'shared',
},
]
}
protected async buildContainerSpec(): Promise<ContainerSpec> {
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 {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: this.descriptor.defaultImage,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
],
envFile: this.translateHostPathToGuest(envFilePath),
env: this.buildGatewayEnv(timezone),
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
addHosts: [`host.containers.internal:${gateway}`],
health: {
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
interval: '30s',
timeout: '10s',
retries: 3,
},
command: [
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
'--allow-unconfigured',
],
}
}
protected async readinessProbe(): Promise<boolean> {
const hostPort = this.hostPort
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
return res.ok
} catch {
return false
}
}
// ── AgentRuntime additions ───────────────────────────────────────
getPerAgentHomeDir(_agentId: string): string {
return this.openclawConfig.openclawDir
}
/** Build the ExecSpec for `openclaw acp` inside the gateway container. */
getAcpExecSpec(input: {
commandEnv: Record<string, string>
openclawSessionKey: string | null
}): ExecSpec {
const argv: [string, ...string[]] = ['openclaw', 'acp']
argv.push('--url', `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`)
const bridgeSessionKey = normalizeBridgeSessionKey(input.openclawSessionKey)
if (bridgeSessionKey) argv.push('--session', bridgeSessionKey)
return {
argv,
env: {
OPENCLAW_HIDE_BANNER: '1',
OPENCLAW_SUPPRESS_NOTES: '1',
...input.commandEnv,
},
}
}
prepareTurnContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
return prepareOpenClawContext(input)
}
// ── OpenClaw-specific surface kept on the runtime ────────────────
/** Run argv in the gateway container; satisfies OpenClawCliClient's ContainerExecutor. */
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.deps.cli.exec(this.descriptor.containerName, command, onLog)
}
/** Run argv in the gateway container with stdout + stderr captured separately. */
async runInContainer(
command: string[],
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return this.deps.cli.runCommand([
'exec',
this.descriptor.containerName,
...command,
])
}
/** Standalone VM-ready entry point used by prewarm / auto-start gating. */
async ensureReady(onLog?: LogFn): Promise<void> {
await this.deps.vm.ensureReady(onLog)
await this.deps.vm.getDefaultGateway()
}
async stopVm(): Promise<void> {
await this.deps.vm.stopVm()
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
const running = await this.deps.vm.isReady()
return { initialized: running, running }
}
isHealthy(): Promise<boolean> {
const hostPort = this.hostPort
return fetchOk(`http://127.0.0.1:${hostPort}/healthz`)
}
/** Public proxy for the readiness probe so callers don't need to
* reach into the protected method. */
isReady(): Promise<boolean> {
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<void> {
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
// swap from the legacy `ContainerRuntime` to this class with
// minimal touch; a follow-up can rename the call sites to use
// `executeAction(...)` directly and drop these wrappers.
/** Pre-pull the gateway image without starting the container. */
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
await this.executeAction({ type: 'install' }, { onLog })
}
/** Start the gateway container with the runtime's own spec. */
async startGateway(_unused?: unknown, onLog?: LogFn): Promise<void> {
await this.executeAction({ type: 'start' }, { onLog })
}
async stopGateway(): Promise<void> {
await this.executeAction({ type: 'stop' })
}
async restartGateway(_unused?: unknown, onLog?: LogFn): Promise<void> {
await this.executeAction({ type: 'restart' }, { onLog })
}
/** Poll readiness until ready or timeout. Returns whether ready. */
async waitForReady(_hostPort?: number, timeoutMs = 30_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.readinessProbe()) return true
await Bun.sleep(1000)
}
return false
}
async getGatewayLogs(tail = 50): Promise<string[]> {
return this.getLogs(tail)
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.tailLogs(onLine)
}
isGatewayCurrent(): Promise<boolean> {
return this.isImageCurrent()
}
/** Run a one-shot command in a `<name>-setup` sibling container. */
async runGatewaySetupCommand(
command: string[],
_unused?: unknown,
onLog?: LogFn,
): Promise<number> {
const argv = command[0] === 'node' ? command.slice(1) : command
const result = await this.runOneShot(['node', ...argv], { onLog })
return result.exitCode
}
// ── Internals ────────────────────────────────────────────────────
private buildGatewayEnv(timezone: string): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
OPENCLAW_NO_RESPAWN: '1',
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
NODE_ENV: 'production',
TZ: timezone,
PATH: GATEWAY_PATH,
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}
}
private translateHostPathToGuest(hostPath: string): string {
const root = this.openclawConfig.openclawDir
if (hostPath === root) return GUEST_OPENCLAW_HOME
if (hostPath.startsWith(`${root}/`)) {
return `${GUEST_OPENCLAW_HOME}${hostPath.slice(root.length)}`
}
// Fall back to the generic VM path translation. acpx-side callers
// never pass paths outside openclawDir today, but the legacy
// implementation tolerated it so we mirror the behaviour.
return hostPath
}
}
async function fetchOk(url: string): Promise<boolean> {
try {
const res = await fetch(url)
return res.ok
} catch {
return false
}
}
/** Normalize an acpx session key into the form OpenClaw expects on
* `--session`: must start with `agent:` and be alphanumeric/dash. */
function normalizeBridgeSessionKey(sessionKey: string | null): string | null {
if (!sessionKey) return null
if (sessionKey.startsWith('agent:')) return sessionKey
return `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
}
/** Prepare OpenClaw without BrowserOS SOUL/MEMORY or BrowserOS MCP. */
export async function prepareOpenClawContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
const paths = resolveAgentRuntimePaths({
browserosDir: input.browserosDir,
agentId: input.agent.id,
})
await ensureUsableCwd(paths.effectiveCwd, true)
return {
cwd: paths.effectiveCwd,
runtimeSessionKey: input.sessionKey,
runPrompt: buildBrowserosAcpPrompt(
OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS,
input.message,
),
commandEnv: {},
commandIdentity: 'openclaw',
useBrowserosMcp: false,
openclawSessionKey: input.sessionKey,
}
}
// ── Factory + wire-up ──────────────────────────────────────────────
export interface ConfigureOpenClawRuntimeOptions {
resourcesDir?: string
browserosDir?: string
}
/** Build an OpenClawContainerRuntime with production deps and register
* it. Idempotent — repeat calls return the already-registered runtime.
* Constructs on every platform so service callers (and tests that
* override `service.runtime` post-construction) work uniformly. The
* descriptor's `platforms: ['darwin']` is the live signal for the UI
* / adapter health, and `start()` itself fails at limactl-not-found
* on non-darwin if anyone actually invokes it. */
export function configureOpenClawRuntime(
options: ConfigureOpenClawRuntimeOptions = {},
): OpenClawContainerRuntime {
const existing = getOpenClawRuntime()
if (existing) return existing
const browserosDir = options.browserosDir ?? getBrowserosDir()
const openclawDir = getOpenClawDir()
const resourcesDir = options.resourcesDir ?? null
// Resolve bundled paths optimistically — on platforms / CI runners
// without Lima, fall back to the bare command names so construction
// succeeds. Lifecycle ops will fail at spawn time with the same
// "not on PATH" error, matching how the other runtimes degrade.
const limactlPath = (() => {
if (!resourcesDir) return 'limactl'
try {
return resolveBundledLimactl(resourcesDir)
} catch (err) {
logger.warn('OpenClaw bundled limactl unavailable; falling back', {
error: err instanceof Error ? err.message : String(err),
})
return 'limactl'
}
})()
const templatePath = (() => {
if (!resourcesDir) return undefined
try {
return resolveBundledLimaTemplate(resourcesDir)
} catch {
return undefined
}
})()
const limaHome = getLimaHomeDir(browserosDir)
const vm = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: browserosDir,
})
const cli = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new ImageLoader(cli)
const runtime = new OpenClawContainerRuntime(
{
cli,
loader,
vm,
limactlPath,
limaHome,
vmName: VM_NAME,
lockDir: join(openclawDir, '.locks'),
},
{ browserosDir, openclawDir },
)
getAgentRuntimeRegistry().register(runtime)
logger.debug('OpenClawContainerRuntime registered', {
image: runtime.descriptor.defaultImage,
})
return runtime
}
export function getOpenClawRuntime(): OpenClawContainerRuntime | null {
const r = getAgentRuntimeRegistry().get('openclaw')
return r instanceof OpenClawContainerRuntime ? r : null
}

View File

@@ -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 (

View File

@@ -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<boolean> {
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<void> {
return this.withLifecycleLock('stop', async () => {

View File

@@ -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 {

View File

@@ -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'
@@ -25,7 +24,9 @@ import {
configureClaudeRuntime,
configureCodexRuntime,
configureHermesRuntime,
configureOpenClawRuntime,
getHermesRuntime,
getOpenClawRuntime,
} from './lib/agents/runtime'
import {
cleanOldSessions,
@@ -68,6 +69,7 @@ export class Application {
configureVmRuntime({ resourcesDir })
configureClaudeRuntime()
configureCodexRuntime()
configureOpenClawRuntime({ resourcesDir })
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -191,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' })

View File

@@ -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<string>
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<void>
getLogs?: () => Promise<string[]>
}
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<string[]>
},
): ContainerAgentRuntime {
// Create a real ContainerAgentRuntime subclass instance so the
// route's `instanceof ContainerAgentRuntime` check passes.
const fakeCli = {
inspectContainer: async (): Promise<ContainerInfo | null> => 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<ContainerSpec> {
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)
})
})
})

View File

@@ -1,143 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
buildContainerRuntime,
migrateLegacyOpenClawDir,
} from '../../../../src/api/services/openclaw/container-runtime-factory'
import { logger } from '../../../../src/lib/logger'
describe('container-runtime factory', () => {
let root: string
let resourcesDir: string
let originalNodeEnv: string | undefined
beforeEach(async () => {
root = await mkdtemp('/tmp/openclaw-runtime-factory-')
resourcesDir = join(root, 'resources')
const limaRoot = join(resourcesDir, 'bin', 'third_party', 'lima')
const limactlPath = join(limaRoot, 'bin', 'limactl')
const armGuestAgentPath = join(
limaRoot,
'share',
'lima',
'lima-guestagent.Linux-aarch64.gz',
)
const x64GuestAgentPath = join(
limaRoot,
'share',
'lima',
'lima-guestagent.Linux-x86_64.gz',
)
await mkdir(dirname(limactlPath), { recursive: true })
await mkdir(dirname(armGuestAgentPath), { recursive: true })
await mkdir(join(resourcesDir, 'vm'), { recursive: true })
await writeFile(limactlPath, '#!/bin/sh\n')
await writeFile(armGuestAgentPath, 'guest-agent\n')
await writeFile(x64GuestAgentPath, 'guest-agent\n')
await writeFile(
join(resourcesDir, 'vm', 'browseros-vm.yaml'),
'mounts: []\n',
)
originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
})
afterEach(async () => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
await rm(root, { recursive: true, force: true })
})
it('rejects non-macOS platforms', () => {
expect(() =>
buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
}),
).toThrow('supports macOS only')
})
it('returns a disabled runtime on non-macOS platforms in test mode', async () => {
process.env.NODE_ENV = 'test'
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
})
await expect(runtime.getMachineStatus()).resolves.toEqual({
initialized: false,
running: false,
})
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
await expect(runtime.prewarmGatewayImage()).rejects.toThrow(
'supports macOS only',
)
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('migrates legacy OpenClaw state into the VM state directory', async () => {
const legacyFile = join(root, 'openclaw', '.openclaw', 'openclaw.json')
await mkdir(dirname(legacyFile), { recursive: true })
await writeFile(legacyFile, '{"ok":true}\n')
await migrateLegacyOpenClawDir(root)
await expect(
readFile(
join(root, 'vm', 'openclaw', '.openclaw', 'openclaw.json'),
'utf8',
),
).resolves.toBe('{"ok":true}\n')
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
})
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'darwin',
})
expect(runtime).toBeDefined()
})
it('leaves both directories in place when new OpenClaw state already exists', async () => {
const legacyFile = join(root, 'openclaw', 'legacy.txt')
const newFile = join(root, 'vm', 'openclaw', 'new.txt')
await mkdir(dirname(legacyFile), { recursive: true })
await mkdir(dirname(newFile), { recursive: true })
await writeFile(legacyFile, 'legacy')
await writeFile(newFile, 'new')
const originalWarn = logger.warn
const warnings: string[] = []
logger.warn = (message) => warnings.push(message)
try {
await migrateLegacyOpenClawDir(root)
} finally {
logger.warn = originalWarn
}
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('legacy')
await expect(readFile(newFile, 'utf8')).resolves.toBe('new')
expect(warnings).toContain(
'OpenClaw legacy and VM state directories both exist',
)
})
})

View File

@@ -1,401 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
import { ContainerNameInUseError } from '../../../../src/lib/vm/errors'
const PROJECT_DIR = '/tmp/openclaw'
const OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
const defaultSpec = {
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
describe('ContainerRuntime', () => {
it('starts the gateway by loading the image, creating, and starting a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_NAME_RELEASE_WAIT,
)
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: OPENCLAW_IMAGE,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: 18789,
containerPort: 18789,
},
],
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
}),
undefined,
)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('reconciles and retries when gateway create reports name-in-use', async () => {
const deps = createDeps()
deps.shell.createContainer = mock(async () => {
if (deps.shell.createContainer.mock.calls.length === 1) {
throw new ContainerNameInUseError(
OPENCLAW_GATEWAY_CONTAINER_NAME,
'nerdctl create',
1,
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
)
}
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(deps.shell.createContainer).toHaveBeenCalledTimes(2)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(2)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('bounds gateway create retries when the name stays in use', async () => {
const deps = createDeps()
deps.shell.createContainer = mock(async () => {
throw new ContainerNameInUseError(
OPENCLAW_GATEWAY_CONTAINER_NAME,
'nerdctl create',
1,
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
)
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.startGateway(defaultSpec)).rejects.toBeInstanceOf(
ContainerNameInUseError,
)
expect(deps.shell.createContainer).toHaveBeenCalledTimes(3)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(3)
expect(deps.shell.startContainer).not.toHaveBeenCalled()
})
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
const previous = process.env.OPENCLAW_IMAGE
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
try {
await runtime.startGateway(defaultSpec)
} finally {
if (previous === undefined) delete process.env.OPENCLAW_IMAGE
else process.env.OPENCLAW_IMAGE = previous
}
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
'localhost/openclaw:test',
undefined,
)
expect(deps.loader.ensureAgentImageLoaded).not.toHaveBeenCalled()
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({ image: 'localhost/openclaw:test' }),
undefined,
)
})
it('passes private-ingress no-auth only when requested', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway({
...defaultSpec,
gatewayToken: undefined,
privateIngressNoAuth: true,
})
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}),
}),
undefined,
)
})
it('delegates ensureReady and stopVm to VmRuntime', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.ensureReady()
await runtime.stopVm()
expect(deps.vm.ensureReady).toHaveBeenCalled()
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
expect(deps.vm.stopVm).toHaveBeenCalled()
})
it('runs setup commands with guest paths', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
expect.arrayContaining([
'create',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
'--env-file',
'/mnt/browseros/vm/openclaw/.openclaw/.env',
'-v',
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
OPENCLAW_IMAGE,
]),
undefined,
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
undefined,
)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
OPENCLAW_NAME_RELEASE_WAIT,
)
})
it('reconciles and retries when setup create reports name-in-use', async () => {
const deps = createDeps()
let setupCreateCount = 0
deps.shell.runCommand = mock(async (args: string[]) => {
if (args[0] === 'create') {
setupCreateCount += 1
if (setupCreateCount === 1) {
return {
exitCode: 1,
stdout: '',
stderr: `name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup" is already used`,
}
}
}
return { exitCode: 0, stdout: '', stderr: '' }
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(
runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
),
).resolves.toBe(0)
expect(setupCreateCount).toBe(2)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
})
it('tails and fetches gateway logs through the new transport', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
const stop = runtime.tailGatewayLogs(() => {})
const logs = await runtime.getGatewayLogs(10)
stop()
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
expect.any(Function),
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
expect.any(Function),
)
expect(logs).toEqual(['log line'])
})
it('prewarms the gateway image without creating a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.prewarmGatewayImage()
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).not.toHaveBeenCalled()
})
it('detects when the gateway container uses the current image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('treats a digest-qualified current image ref as current', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
})
it('detects when the gateway container uses an old image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => 'ghcr.io/openclaw/openclaw:old',
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
})
})
function createDeps() {
return {
vm: {
ensureReady: mock(async () => {}),
getDefaultGateway: mock(async () => '192.168.5.2'),
stopVm: mock(async () => {}),
isReady: mock(async () => true),
},
shell: {
createContainer: mock(async () => {}),
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
containerImageRef: mock(async () => OPENCLAW_IMAGE),
waitForContainerNameRelease: mock(async () => {}),
exec: mock(async () => 0),
runCommand: mock(
async (_args: string[], onLog?: (line: string) => void) => {
onLog?.('log line')
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
},
),
tailLogs: mock(() => () => {}),
},
loader: {
ensureImageLoaded: mock(async () => {}),
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
},
}
}

View File

@@ -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,
@@ -372,14 +334,7 @@ describe('OpenClawService', () => {
model: undefined,
})
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
expect.any(Function),
)
expect(startGateway).toHaveBeenCalledTimes(1)
expect(startGateway.mock.calls[0]?.[0]).not.toHaveProperty('image')
expect(restartGateway).not.toHaveBeenCalled()
})
@@ -401,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,
@@ -439,6 +395,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
getHostPort: () => 18789,
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
@@ -510,6 +467,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
getHostPort: () => 18789,
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
@@ -573,381 +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).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
expect.any(Function),
)
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<void>((resolve) => {
notifyStartGatewayEntered = resolve
})
const unblockStartGateway = new Promise<void>((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<void>((resolve) => {
notifyStartGatewayEntered = resolve
})
const unblockStartGateway = new Promise<void>((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).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
expect.any(Function),
)
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,
isReady: async (hostPort?: number) => hostPort === occupiedPort,
restartGateway,
waitForReady,
}
service.cliClient = {
probe,
}
mockGatewayAuth()
await service.restart()
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: occupiedPort,
}),
expect.any(Function),
)
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,
isReady: async (hostPort?: number) => hostPort === occupiedPort,
restartGateway,
waitForReady,
}
service.cliClient = {
probe,
}
mockGatewayAuth(401)
await service.restart()
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
}),
expect.any(Function),
)
expect(
(restartGateway.mock.calls[0]?.[0] as { hostPort: number }).hostPort,
).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,
}
@@ -956,199 +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)
await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}')
const executeAction = mock(async () => {})
const syncState = mock(async () => {})
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).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
expect(isReady).toHaveBeenCalledTimes(2)
})
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 probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady,
isGatewayCurrent,
startGateway,
}
service.cliClient = { probe }
mockGatewayAuth()
await service.tryAutoStart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(isGatewayCurrent).toHaveBeenCalledTimes(1)
expect(startGateway).not.toHaveBeenCalled()
expect(probe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart reuses a ready no-auth gateway without Authorization', 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,
getHostPort: () => 18789,
getStatusSnapshot: () => ({ state: 'stopped' as const }),
syncState,
executeAction,
}
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(syncState).toHaveBeenCalledTimes(1)
expect(executeAction).toHaveBeenCalledWith({ type: 'start' })
expect(probe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart recreates a ready gateway when the image is stale', 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: { 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)
await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}')
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)
})
@@ -1283,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 = {
@@ -1323,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 = {
@@ -1371,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 = {
@@ -1471,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 = {
@@ -1519,7 +966,6 @@ describe('OpenClawService', () => {
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.restart = restart
await expect(
service.updateProviderKeys({
@@ -1553,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),
}
@@ -1612,7 +1059,6 @@ describe('OpenClawService', () => {
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.restart = restart
await service.updateProviderKeys({
providerType: 'openai',
@@ -1639,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,
}
@@ -1677,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,
}
@@ -1697,16 +1147,3 @@ describe('OpenClawService', () => {
)
})
})
function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
const fetchMock = mock(() => Promise.resolve(new Response('', { status })))
globalThis.fetch = fetchMock as typeof globalThis.fetch
return fetchMock
}
function fetchHeaders(
fetchMock: ReturnType<typeof mock>,
): Record<string, string> {
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
{}) as Record<string, string>
}

View File

@@ -24,6 +24,7 @@ import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import {
getAgentRuntimeRegistry,
HermesContainerRuntime,
OpenClawContainerRuntime,
resetAgentRuntimeRegistry,
} from '../../../src/lib/agents/runtime'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
@@ -1112,17 +1113,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
})
it('resolves the openclaw adapter to a lima/nerdctl exec command', async () => {
registerFakeOpenClawRuntime({
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/Users/dev/.browseros-dev/lima',
vmName: 'browseros-vm',
})
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd: '/tmp/browseros-acpx-runtime',
stateDir: '/tmp/browseros-acpx-state',
openclawGateway: {
getGatewayToken: () => 'test-token-abc',
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/opt/homebrew/bin/limactl',
getVmName: () => 'browseros-vm',
},
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
@@ -1171,17 +1170,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
})
it('rewrites non-harness OpenClaw session keys onto the gateway main agent', async () => {
registerFakeOpenClawRuntime({
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/Users/dev/.browseros-dev/lima',
vmName: 'browseros-vm',
})
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd: '/tmp/browseros-acpx-runtime',
stateDir: '/tmp/browseros-acpx-state',
openclawGateway: {
getGatewayToken: () => 'test-token-abc',
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/opt/homebrew/bin/limactl',
getVmName: () => 'browseros-vm',
},
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
@@ -1368,6 +1365,30 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
})
})
function registerFakeOpenClawRuntime(opts: {
limactlPath: string
limaHome: string
vmName: string
}): OpenClawContainerRuntime {
resetAgentRuntimeRegistry()
const fakeDeps: ManagedContainerDeps = {
cli: {} as ManagedContainerDeps['cli'],
loader: {} as ManagedContainerDeps['loader'],
vm: {} as ManagedContainerDeps['vm'],
limactlPath: opts.limactlPath,
limaHome: opts.limaHome,
vmName: opts.vmName,
lockDir: '/tmp/openclaw-test-locks',
}
const runtime = new OpenClawContainerRuntime(fakeDeps, {
browserosDir: '/tmp/browseros-test',
openclawDir: '/tmp/browseros-test/vm/openclaw',
})
runtime.setHostPort(18789)
getAgentRuntimeRegistry().register(runtime)
return runtime
}
function makeAgent(input: {
id: string
adapter: AgentDefinition['adapter']

View File

@@ -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')
})

View File

@@ -0,0 +1,319 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '../../../../../../packages/shared/src/constants/openclaw'
import {
configureOpenClawRuntime,
getAgentRuntimeRegistry,
getOpenClawRuntime,
OpenClawContainerRuntime,
resetAgentRuntimeRegistry,
} from '../../../../src/lib/agents/runtime'
import type {
ManagedContainerDeps,
MountRoot,
} from '../../../../src/lib/container/managed'
import type {
ContainerInfo,
ContainerSpec,
} from '../../../../src/lib/container/types'
interface FakeCli {
inspectContainer: (name: string) => Promise<ContainerInfo | null>
removeContainer: (name: string, opts?: { force?: boolean }) => Promise<void>
waitForContainerNameRelease: () => Promise<void>
createContainer: (spec: ContainerSpec) => Promise<void>
startContainer: (name: string) => Promise<void>
waitForContainerRunning: (name: string) => Promise<void>
exec: (name: string, cmd: string[]) => Promise<number>
}
function makeDeps(opts: { lockDir: string }): {
deps: ManagedContainerDeps
getCapturedSpec: () => ContainerSpec | null
} {
let capturedSpec: ContainerSpec | null = null
const fakeCli = {
inspectContainer: async (): Promise<ContainerInfo | null> => ({
id: 'cid',
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: 'docker.io/openclaw:latest',
status: 'running',
running: true,
ports: [],
}),
removeContainer: async () => {},
waitForContainerNameRelease: async () => {},
createContainer: async (spec: ContainerSpec) => {
capturedSpec = spec
},
startContainer: async () => {},
waitForContainerRunning: async () => {},
exec: async () => 0,
} satisfies FakeCli
const fakeLoader = { ensureImageLoaded: async () => {} }
const fakeVm = {
ensureReady: async () => {},
getDefaultGateway: async () => '192.168.5.2',
isReady: async () => true,
stopVm: async () => {},
}
const deps: ManagedContainerDeps = {
cli: fakeCli as unknown as ManagedContainerDeps['cli'],
loader: fakeLoader as unknown as ManagedContainerDeps['loader'],
vm: fakeVm as unknown as ManagedContainerDeps['vm'],
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/Users/dev/.browseros/lima',
vmName: 'browseros-vm',
lockDir: opts.lockDir,
}
return { deps, getCapturedSpec: () => capturedSpec }
}
describe('OpenClawContainerRuntime', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
resetAgentRuntimeRegistry()
})
function mkTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'openclaw-runtime-test-'))
tempDirs.push(dir)
return dir
}
class TestRuntime extends OpenClawContainerRuntime {
// Override the live HTTP probe so tests don't need a real server.
protected override async readinessProbe(): Promise<boolean> {
return true
}
}
function makeRuntime() {
const lockDir = mkTempDir()
const browserosDir = mkTempDir()
const openclawDir = join(browserosDir, 'vm/openclaw')
const { deps, getCapturedSpec } = makeDeps({ lockDir })
const runtime = new TestRuntime(deps, { browserosDir, openclawDir })
return { runtime, getCapturedSpec, browserosDir, openclawDir }
}
it('declares the canonical OpenClaw runtime descriptor', () => {
const { runtime } = makeRuntime()
expect(runtime.descriptor.adapterId).toBe('openclaw')
expect(runtime.descriptor.kind).toBe('container')
expect(runtime.descriptor.containerName).toBe(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
expect(runtime.descriptor.platforms).toContain('darwin')
})
it('mountRoots maps the openclaw state dir to the gateway container home', () => {
const { runtime, openclawDir } = makeRuntime()
const mounts: readonly MountRoot[] = (
runtime as unknown as { mountRoots(): readonly MountRoot[] }
).mountRoots()
expect(mounts).toEqual([
{
hostPath: openclawDir,
containerPath: '/home/node',
kind: 'shared',
},
])
})
it('setHostPort updates the port referenced by buildContainerSpec', async () => {
const { runtime, getCapturedSpec } = makeRuntime()
runtime.setHostPort(41091)
await runtime.start()
const spec = getCapturedSpec()
if (!spec) throw new Error('createContainer was never called')
expect(spec.ports).toEqual([
{
hostIp: '127.0.0.1',
hostPort: 41091,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
])
})
it('builds the gateway spec with sleep-free entrypoint, mount, host-gateway, and command', async () => {
const { runtime, getCapturedSpec } = makeRuntime()
await runtime.start()
const spec = getCapturedSpec()
if (!spec) throw new Error('createContainer was never called')
expect(spec.command?.[0]).toBe('node')
expect(spec.command).toEqual(
expect.arrayContaining([
'gateway',
'--bind',
'lan',
'--allow-unconfigured',
]),
)
expect(spec.addHosts).toContain('host.containers.internal:192.168.5.2')
expect(spec.mounts).toEqual([
{ source: '/mnt/browseros/vm/openclaw', target: '/home/node' },
])
expect(spec.env?.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH).toBe('1')
})
it('getAcpExecSpec composes the openclaw acp argv with optional --session', () => {
const { runtime } = makeRuntime()
const noSession = runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: null,
})
expect(noSession.argv).toEqual([
'openclaw',
'acp',
'--url',
`ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`,
])
expect(noSession.env?.OPENCLAW_HIDE_BANNER).toBe('1')
expect(noSession.env?.OPENCLAW_SUPPRESS_NOTES).toBe('1')
const withSession = runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: 'agent:research:main',
})
expect(withSession.argv).toEqual(
expect.arrayContaining(['--session', 'agent:research:main']),
)
const withSyntheticSession = runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: 'sidepanel:c0ffee:openclaw:default:medium',
})
expect(withSyntheticSession.argv).toEqual(
expect.arrayContaining([
'--session',
'agent:main:sidepanel-c0ffee-openclaw-default-medium',
]),
)
})
it('buildExecArgv produces the canonical limactl/nerdctl spawn string', () => {
const { runtime } = makeRuntime()
const out = runtime.buildExecArgv(
runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: 'agent:main:main',
}),
)
expect(out).toContain('LIMA_HOME=/Users/dev/.browseros/lima')
expect(out).toContain('shell --workdir / browseros-vm --')
expect(out).toContain('nerdctl exec -i')
expect(out).toContain(OPENCLAW_GATEWAY_CONTAINER_NAME)
expect(out).toContain('openclaw acp --url ws://127.0.0.1:18789')
expect(out).toContain('-e OPENCLAW_HIDE_BANNER=1')
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<ContainerInfo | null> => ({
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 —
// their semantics are exercised by the openclaw-service tests.
expect(typeof runtime.startGateway).toBe('function')
expect(typeof runtime.stopGateway).toBe('function')
expect(typeof runtime.restartGateway).toBe('function')
expect(typeof runtime.prewarmGatewayImage).toBe('function')
expect(typeof runtime.getGatewayLogs).toBe('function')
expect(typeof runtime.tailGatewayLogs).toBe('function')
expect(typeof runtime.isGatewayCurrent).toBe('function')
expect(typeof runtime.runGatewaySetupCommand).toBe('function')
})
describe('configureOpenClawRuntime', () => {
let originalPlatform: string
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
it('registers on darwin and is idempotent across repeat calls', () => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin' })
const browserosDir = mkTempDir()
const first = configureOpenClawRuntime({ browserosDir })
const second = configureOpenClawRuntime({ browserosDir })
expect(first).toBeInstanceOf(OpenClawContainerRuntime)
expect(second).toBe(first)
expect(getAgentRuntimeRegistry().get('openclaw')).toBe(first)
})
it('also registers on non-darwin so callers get a real instance back; lifecycle ops fail at use time', () => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'linux' })
const browserosDir = mkTempDir()
const runtime = configureOpenClawRuntime({ browserosDir })
expect(runtime).toBeInstanceOf(OpenClawContainerRuntime)
expect(getOpenClawRuntime()).toBe(runtime)
})
})
})

View File

@@ -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 },

View File

@@ -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<boolean> }
).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 })

View File

@@ -221,6 +221,9 @@ async function setupApplicationTest() {
spyOn(runtimeModule, 'configureCodexRuntime').mockImplementation(
() => ({}) as never,
)
spyOn(runtimeModule, 'configureOpenClawRuntime').mockImplementation(
() => ({}) as never,
)
const { Application } = await import('../src/main')
return {