mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
22 Commits
dev
...
feat/runti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77031025b | ||
|
|
b6172a4109 | ||
|
|
9632b60425 | ||
|
|
fdc6b80395 | ||
|
|
4806eb414d | ||
|
|
7392244574 | ||
|
|
d6440bdccd | ||
|
|
349c3743a9 | ||
|
|
830eebae82 | ||
|
|
4ccb7ac0fd | ||
|
|
ab63827b69 | ||
|
|
8f68d12339 | ||
|
|
af16f1cc0c | ||
|
|
c099a35dee | ||
|
|
8eb911d83f | ||
|
|
983e433845 | ||
|
|
4401e30fdc | ||
|
|
5da13e54b5 | ||
|
|
5ea8cff1b6 | ||
|
|
b494bbd41c | ||
|
|
f313aa532d | ||
|
|
a23fd55934 |
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
167
packages/browseros-agent/apps/server/src/api/routes/runtimes.ts
Normal file
167
packages/browseros-agent/apps/server/src/api/routes/runtimes.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user