mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
54 Commits
fix/cache-
...
exp/click_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b7ec397e | ||
|
|
6dec35475b | ||
|
|
f9c56546cc | ||
|
|
670b8d9745 | ||
|
|
76b6869219 | ||
|
|
95303f4374 | ||
|
|
bccccce0a7 | ||
|
|
cda9965927 | ||
|
|
31eb93bdf8 | ||
|
|
3386f0a5ce | ||
|
|
b8ed09eaba | ||
|
|
907f10e7c8 | ||
|
|
db8bec9b59 | ||
|
|
2879574219 | ||
|
|
7125029049 | ||
|
|
e6c6c29472 | ||
|
|
d35c02b223 | ||
|
|
b0c5383407 | ||
|
|
bf9fc96f42 | ||
|
|
307c02c44a | ||
|
|
d865931db3 | ||
|
|
4e55be8b9f | ||
|
|
a39dfa52f3 | ||
|
|
a4584142a1 | ||
|
|
f538615a4f | ||
|
|
08968ff16e | ||
|
|
dca8b8555f | ||
|
|
98cc128d3b | ||
|
|
a6ae8bba56 | ||
|
|
a5c3769e4e | ||
|
|
4051fe189b | ||
|
|
eb08cac743 | ||
|
|
144a10946d | ||
|
|
e30f29dd06 | ||
|
|
b4e08d3a13 | ||
|
|
4284e88625 | ||
|
|
0b91c735ab | ||
|
|
d189b50b03 | ||
|
|
a407e48209 | ||
|
|
1f75b91fba | ||
|
|
752f42d1fe | ||
|
|
2f8e36546f | ||
|
|
461dcd29e8 | ||
|
|
c6c902a4ab | ||
|
|
6e37742a5a | ||
|
|
1186c2c0d7 | ||
|
|
0288cc040d | ||
|
|
07b7bf5977 | ||
|
|
d1a3d67e29 | ||
|
|
35134518f0 | ||
|
|
4083155e81 | ||
|
|
72ef4f068e | ||
|
|
6b6ed1582c | ||
|
|
a3764e7599 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ packages/browseros/build/tools/
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
.omc/
|
||||
packages/browseros-agent/tools/dogfood/browseros-dogfood
|
||||
|
||||
@@ -75,26 +75,20 @@ packages/
|
||||
|
||||
### Setup
|
||||
|
||||
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
|
||||
|
||||
```bash
|
||||
brew install process-compose
|
||||
```
|
||||
|
||||
```bash
|
||||
# Copy environment files for each package
|
||||
cp apps/server/.env.example apps/server/.env.development
|
||||
cp apps/agent/.env.example apps/agent/.env.development
|
||||
cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
# Install deps, generate agent code, and sync the VM cache
|
||||
bun run dev:setup
|
||||
|
||||
# Start the full dev environment
|
||||
process-compose up
|
||||
bun run dev:watch
|
||||
```
|
||||
|
||||
The `process-compose up` command runs the following in order:
|
||||
1. `bun install` — installs dependencies
|
||||
2. `bun --cwd apps/agent codegen` — generates agent code
|
||||
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
|
||||
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|
||||
@@ -74,6 +74,18 @@ const primaryNavItems: NavItem[] = [
|
||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||
]
|
||||
|
||||
function isNavItemActive(item: NavItem, pathname: string): boolean {
|
||||
if (item.to === '/settings/ai') {
|
||||
return pathname.startsWith('/settings')
|
||||
}
|
||||
|
||||
if (item.to === '/agents') {
|
||||
return pathname === '/agents' || pathname.startsWith('/agents/')
|
||||
}
|
||||
|
||||
return pathname === item.to
|
||||
}
|
||||
|
||||
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
expanded = true,
|
||||
}) => {
|
||||
@@ -90,10 +102,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
<nav className="space-y-1">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
item.to === '/settings/ai'
|
||||
? location.pathname.startsWith('/settings')
|
||||
: location.pathname === item.to
|
||||
const isActive = isNavItemActive(item, location.pathname)
|
||||
|
||||
const navItem = (
|
||||
<NavLink
|
||||
|
||||
@@ -113,7 +113,22 @@ export const App: FC = () => {
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
{alphaEnabled ? (
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<>
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route element={<AgentCommandLayout />}>
|
||||
<Route
|
||||
path="agents/:agentId"
|
||||
element={
|
||||
<AgentCommandConversation
|
||||
variant="page"
|
||||
backPath="/agents"
|
||||
agentPathPrefix="/agents"
|
||||
createAgentPath="/agents"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
) : null}
|
||||
{alphaEnabled ? (
|
||||
<Route path="admin" element={<AdminDashboardPage />} />
|
||||
|
||||
@@ -1,189 +1,318 @@
|
||||
import { Bot, Home, RotateCcw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Bot, Home } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
flattenHistoryPages,
|
||||
} from './claw-chat-types'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import {
|
||||
CLAW_CHAT_QUERY_KEYS,
|
||||
useClawAgentSession,
|
||||
useClawChatHistory,
|
||||
} from './useClawChatHistory'
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'Working on your request'
|
||||
? 'bg-amber-500'
|
||||
: status === 'Ready'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'Offline'
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-[var(--accent-orange)]',
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIdentity({
|
||||
name,
|
||||
meta,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
meta: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs leading-5">
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
agentMeta,
|
||||
status,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
onReset,
|
||||
}: {
|
||||
agentName: string
|
||||
agentMeta: string
|
||||
status: string
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<Home className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">{agentName}</div>
|
||||
<div className="truncate text-muted-foreground text-sm">
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="rounded-xl text-muted-foreground"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
New conversation
|
||||
<BackIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center py-10">
|
||||
<div className="max-w-md rounded-[1.5rem] border border-border/60 bg-card/90 px-8 py-10 text-center shadow-sm backdrop-blur">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-6" />
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={agentName} meta={agentMeta} />
|
||||
</div>
|
||||
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
|
||||
return (
|
||||
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
<h2 className="mt-4 font-semibold text-lg">{agentName}</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
Send a message to start a focused conversation with this agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getConversationStatusCopy(
|
||||
status: string | undefined,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
if (streaming) return 'Working on your request'
|
||||
if (status === 'running') return 'Ready for the next task'
|
||||
if (status === 'starting') return 'Connecting to OpenClaw'
|
||||
if (status === 'error') return 'OpenClaw needs attention'
|
||||
if (status === 'stopped') return 'OpenClaw is offline'
|
||||
return 'Open agent setup to continue'
|
||||
function AgentRailList({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId: string
|
||||
agents: AgentEntry[]
|
||||
onSelectAgent: (entry: AgentEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{agents.map((entry) => {
|
||||
const active = entry.agentId === activeAgentId
|
||||
const modelName = getModelDisplayName(entry.model) ?? 'OpenClaw agent'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(entry)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={entry.name} meta={modelName} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC = () => {
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
function getConversationStatusCopy(status: string | undefined): string {
|
||||
if (status === 'running') return 'Ready'
|
||||
if (status === 'starting') return 'Connecting'
|
||||
if (status === 'error') return 'Attention'
|
||||
if (status === 'stopped') return 'Offline'
|
||||
return 'Setup'
|
||||
}
|
||||
|
||||
function AgentConversationController({
|
||||
agentId,
|
||||
initialMessage,
|
||||
onInitialMessageConsumed,
|
||||
status,
|
||||
agents,
|
||||
agentPathPrefix,
|
||||
createAgentPath,
|
||||
}: {
|
||||
agentId: string
|
||||
initialMessage: string | null
|
||||
onInitialMessageConsumed: () => void
|
||||
status: ReturnType<typeof useAgentCommandData>['status']
|
||||
agents: AgentEntry[]
|
||||
agentPathPrefix: string
|
||||
createAgentPath: string
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const initialQuerySent = useRef(false)
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const { turns, streaming, loading, send, resetConversation } =
|
||||
useAgentConversation(resolvedAgentId, agentName)
|
||||
const lastTurn = turns[turns.length - 1]
|
||||
const lastTurnPartCount = lastTurn?.parts.length ?? 0
|
||||
const initialMessageSentRef = useRef<string | null>(null)
|
||||
const onInitialMessageConsumedRef = useRef(onInitialMessageConsumed)
|
||||
const [streamSessionKey, setStreamSessionKey] = useState<string | null>(null)
|
||||
const agent = agents.find((entry) => entry.agentId === agentId)
|
||||
const agentName = agent?.name || agentId || 'Agent'
|
||||
const sessionQuery = useClawAgentSession(agentId)
|
||||
const resolvedSessionKey =
|
||||
streamSessionKey ?? sessionQuery.data?.sessionKey ?? null
|
||||
const historyQuery = useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
enabled: Boolean(resolvedSessionKey),
|
||||
})
|
||||
|
||||
const historyMessages = useMemo(
|
||||
() => flattenHistoryPages(historyQuery.data?.pages ?? []),
|
||||
[historyQuery.data?.pages],
|
||||
)
|
||||
const chatHistory = useMemo(
|
||||
() => buildChatHistoryFromClawMessages(historyMessages),
|
||||
[historyMessages],
|
||||
)
|
||||
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
sessionKey: resolvedSessionKey,
|
||||
history: chatHistory,
|
||||
onSessionKeyChange: (sessionKey) => {
|
||||
setStreamSessionKey(sessionKey)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [CLAW_CHAT_QUERY_KEYS.session],
|
||||
})
|
||||
},
|
||||
})
|
||||
const sendRef = useRef(send)
|
||||
sendRef.current = send
|
||||
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||
|
||||
const disabled = status?.status !== 'running'
|
||||
const isInitialLoading =
|
||||
sessionQuery.isLoading ||
|
||||
(Boolean(resolvedSessionKey) && historyQuery.isLoading)
|
||||
const historyReady =
|
||||
!resolvedSessionKey || historyQuery.isFetched || historyQuery.isError
|
||||
const initialMessageKey = initialMessage
|
||||
? `${agentId}:${initialMessage}`
|
||||
: null
|
||||
const error = sessionQuery.error ?? historyQuery.error ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectHome) return
|
||||
|
||||
const query = searchParams.get('q')
|
||||
if (query && !initialQuerySent.current && !loading) {
|
||||
initialQuerySent.current = true
|
||||
setSearchParams({}, { replace: true })
|
||||
void send(query)
|
||||
const query = initialMessage?.trim()
|
||||
if (!initialMessageKey) {
|
||||
initialMessageSentRef.current = null
|
||||
return
|
||||
}
|
||||
}, [loading, searchParams, send, setSearchParams, shouldRedirectHome])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldRedirectHome ||
|
||||
(turns.length === 0 && lastTurnPartCount === 0 && !streaming)
|
||||
!query ||
|
||||
initialMessageSentRef.current === initialMessageKey ||
|
||||
disabled ||
|
||||
sessionQuery.isLoading ||
|
||||
!historyReady ||
|
||||
streaming
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
initialMessageSentRef.current = initialMessageKey
|
||||
onInitialMessageConsumedRef.current()
|
||||
void sendRef.current(query)
|
||||
}, [
|
||||
disabled,
|
||||
historyReady,
|
||||
initialMessage,
|
||||
initialMessageKey,
|
||||
sessionQuery.isLoading,
|
||||
streaming,
|
||||
])
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`/home/agents/${entry.agentId}`)
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status, streaming)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
status={statusCopy}
|
||||
onGoHome={() => navigate('/home')}
|
||||
onReset={resetConversation}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ClawChat
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
turns={turns}
|
||||
streaming={streaming}
|
||||
isInitialLoading={isInitialLoading}
|
||||
error={error}
|
||||
hasNextPage={Boolean(historyQuery.hasNextPage)}
|
||||
isFetchingNextPage={historyQuery.isFetchingNextPage}
|
||||
onFetchNextPage={() => {
|
||||
void historyQuery.fetchNextPage()
|
||||
}}
|
||||
onRetry={() => {
|
||||
void sessionQuery.refetch()
|
||||
void historyQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
|
||||
<main
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'styled-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-[1.5rem] border border-border/50 bg-card/85 px-5 py-5 shadow-sm',
|
||||
'[&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Loading conversation...
|
||||
</div>
|
||||
) : turns.length === 0 ? (
|
||||
<EmptyConversationState agentName={agentName} />
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="w-full flex-shrink-0">
|
||||
<div className="border-border/50 border-t bg-background/88 px-4 py-3 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="conversation"
|
||||
agents={agents}
|
||||
selectedAgentId={resolvedAgentId}
|
||||
selectedAgentId={agentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={(text) => {
|
||||
void send(text)
|
||||
}}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
onCreateAgent={() => navigate(createAgentPath)}
|
||||
streaming={streaming}
|
||||
disabled={status?.status !== 'running'}
|
||||
disabled={disabled}
|
||||
status={status?.status}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
/>
|
||||
@@ -192,3 +321,76 @@ export const AgentCommandConversation: FC = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentCommandConversationProps {
|
||||
variant?: 'command' | 'page'
|
||||
backPath?: string
|
||||
agentPathPrefix?: string
|
||||
createAgentPath?: string
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
variant = 'command',
|
||||
backPath = '/home',
|
||||
agentPathPrefix = '/home/agents',
|
||||
createAgentPath = '/agents',
|
||||
}) => {
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const agentMeta = getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
const initialMessage = searchParams.get('q')
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
|
||||
<AgentRailHeader onGoHome={() => navigate(backPath)} />
|
||||
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
agentMeta={agentMeta}
|
||||
status={statusCopy}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
/>
|
||||
|
||||
<AgentRailList
|
||||
activeAgentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
status={status}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { ArrowRight, Bot, Plus, Settings2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
|
||||
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
|
||||
import { ScheduleResults } from '@/entrypoints/newtab/index/ScheduleResults'
|
||||
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||
import { TopSites } from '@/entrypoints/newtab/index/TopSites'
|
||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||
import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
@@ -22,13 +19,19 @@ function AgentCommandSetupState({
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
Set up OpenClaw agents to turn your new tab into an agent command
|
||||
center.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">Set up your first agent</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Connect OpenClaw and create an agent before using the new tab as
|
||||
your workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
@@ -39,13 +42,19 @@ function AgentCommandSetupState({
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is running, but you do not have any agents yet.
|
||||
</p>
|
||||
<Button variant="outline" onClick={onOpenAgents}>
|
||||
Create your first agent
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Plus className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">No agents yet</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Create an agent to start using BrowserOS as an agent-first new tab.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onOpenAgents} className="rounded-xl">
|
||||
Create agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -58,13 +67,19 @@ function OpenClawUnavailableState({
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is unavailable right now. Open the Agents page to restart the
|
||||
gateway or review setup.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Settings2 className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">OpenClaw is unavailable</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Review your agent setup to restart the gateway or reconnect the
|
||||
local service.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
@@ -73,18 +88,54 @@ function OpenClawUnavailableState({
|
||||
)
|
||||
}
|
||||
|
||||
function RecentThreads({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onOpenAgents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId?: string | null
|
||||
agents: ReturnType<typeof useAgentCardData>
|
||||
onOpenAgents: () => void
|
||||
onSelectAgent: (agentId: string) => void
|
||||
}) {
|
||||
if (agents.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Recent agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Continue from where you left off.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onOpenAgents}
|
||||
className="rounded-xl"
|
||||
size="sm"
|
||||
>
|
||||
Manage agents
|
||||
</Button>
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={agents}
|
||||
activeAgentId={activeAgentId ?? undefined}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onOpenAgents}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const cardData = useAgentCardData(agents, status?.status)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
if (selectedAgentId) {
|
||||
@@ -117,62 +168,65 @@ export const AgentCommandHome: FC = () => {
|
||||
openClawStatus !== 'running' &&
|
||||
openClawStatus !== 'uninitialized' &&
|
||||
cardData.length === 0
|
||||
const selectedCard =
|
||||
cardData.find((agent) => agent.agentId === selectedAgentId) ?? cardData[0]
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
<div className="relative w-full space-y-8 md:w-3xl">
|
||||
<NewTabBranding />
|
||||
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? undefined
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
/>
|
||||
|
||||
{mounted ? <NewTabTip /> : null}
|
||||
|
||||
<div className="min-h-full px-4 py-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
|
||||
{isSetup ? (
|
||||
shouldShowUnavailableState ? (
|
||||
<OpenClawUnavailableState
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
/>
|
||||
) : cardData.length > 0 ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick up where your agents left off.
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AgentCardDock
|
||||
|
||||
<Separator />
|
||||
|
||||
<RecentThreads
|
||||
activeAgentId={selectedAgentId}
|
||||
agents={cardData}
|
||||
activeAgentId={selectedAgentId ?? undefined}
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||
)
|
||||
) : (
|
||||
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
|
||||
)}
|
||||
|
||||
{mounted ? <TopSites /> : null}
|
||||
{mounted ? <ScheduleResults /> : null}
|
||||
</div>
|
||||
|
||||
{activeHint === 'signin' ? <SignInHint /> : null}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ClawChatMessage } from './ClawChatMessage'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
|
||||
|
||||
interface ClawChatProps {
|
||||
agentName: string
|
||||
historyMessages: ClawChatMessageModel[]
|
||||
turns: AgentConversationTurn[]
|
||||
streaming: boolean
|
||||
isInitialLoading: boolean
|
||||
error: Error | null
|
||||
hasNextPage: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onFetchNextPage: () => void
|
||||
onRetry: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 py-12">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-3xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-6" />
|
||||
</div>
|
||||
<h2 className="mt-5 font-semibold text-xl">{agentName}</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm leading-6">
|
||||
Ask {agentName} to start a task.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingConversationState() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading conversation...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string
|
||||
onRetry: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 py-12">
|
||||
<div className="max-w-md rounded-2xl border border-border/60 bg-card px-5 py-4 text-center shadow-sm">
|
||||
<p className="text-sm">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClawChat: FC<ClawChatProps> = ({
|
||||
agentName,
|
||||
historyMessages,
|
||||
turns,
|
||||
streaming,
|
||||
isInitialLoading,
|
||||
error,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onRetry,
|
||||
className,
|
||||
}) => {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||
const onFetchNextPageRef = useRef(onFetchNextPage)
|
||||
onFetchNextPageRef.current = onFetchNextPage
|
||||
const hasMessages = historyMessages.length > 0 || turns.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = topSentinelRef.current
|
||||
if (!sentinel) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (!entry?.isIntersecting || !hasNextPage || isFetchingNextPage) {
|
||||
return
|
||||
}
|
||||
|
||||
onFetchNextPageRef.current()
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '160px 0px 0px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [hasNextPage, isFetchingNextPage])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex min-h-0 flex-1 flex-col overflow-hidden', className)}
|
||||
>
|
||||
<Conversation
|
||||
className={cn(
|
||||
'bg-background',
|
||||
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
<ConversationContent className="min-h-full px-5 py-5">
|
||||
{isInitialLoading ? (
|
||||
<LoadingConversationState />
|
||||
) : error && !hasMessages ? (
|
||||
<ConversationErrorState message={error.message} onRetry={onRetry} />
|
||||
) : !hasMessages ? (
|
||||
<EmptyConversationState agentName={agentName} />
|
||||
) : (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
|
||||
<div ref={topSentinelRef} aria-hidden="true" className="h-px" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2 text-muted-foreground text-xs">
|
||||
<Loader2 className="mr-2 size-3.5 animate-spin" />
|
||||
Loading older messages...
|
||||
</div>
|
||||
) : null}
|
||||
{!hasNextPage && historyMessages.length > 0 ? (
|
||||
<div className="py-1 text-center text-muted-foreground text-xs">
|
||||
Start of conversation
|
||||
</div>
|
||||
) : null}
|
||||
{historyMessages.map((message) => (
|
||||
<ClawChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-border/60 bg-card px-4 py-3 text-muted-foreground text-sm">
|
||||
{error.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ClawChatMessage as ClawChatMessageType } from './claw-chat-types'
|
||||
|
||||
interface ClawChatMessageProps {
|
||||
message: ClawChatMessageType
|
||||
}
|
||||
|
||||
export const ClawChatMessage: FC<ClawChatMessageProps> = ({ message }) => (
|
||||
<Message
|
||||
from={message.role}
|
||||
className="max-w-full group-[.is-user]:max-w-[80%]"
|
||||
>
|
||||
<MessageContent className="max-w-full overflow-hidden group-[.is-assistant]:w-full group-[.is-user]:max-w-full">
|
||||
{message.parts.map((part, index) => {
|
||||
const key = `${message.id}-part-${index}`
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse
|
||||
key={key}
|
||||
className={cn(
|
||||
'max-w-full overflow-hidden break-words',
|
||||
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto',
|
||||
'[&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
'[&_table]:w-max [&_table]:min-w-full',
|
||||
)}
|
||||
>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning key={key} className="w-full" defaultOpen={false}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-call':
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{part.status === 'running' || part.status === 'pending' ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
{part.status === 'completed' ? (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
) : null}
|
||||
{part.status === 'failed' ? (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
) : null}
|
||||
<span className="font-mono text-xs">{part.name}</span>
|
||||
{part.error ? (
|
||||
<span className="ml-auto text-destructive text-xs">
|
||||
{part.error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'meta':
|
||||
return (
|
||||
<div key={key} className="text-muted-foreground text-xs">
|
||||
{part.label}: {part.value}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
@@ -8,11 +8,19 @@ import {
|
||||
Mic,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import { type FC, type ReactNode, useEffect, useState } from 'react'
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
@@ -146,7 +154,7 @@ function ContextControls({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<AgentSelector
|
||||
@@ -234,7 +242,7 @@ function ContextControls({
|
||||
|
||||
function HomeShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -242,7 +250,7 @@ function HomeShell({ children }: { children: ReactNode }) {
|
||||
|
||||
function ConversationShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -262,10 +270,27 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}) => {
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [isExpandedDraft, setIsExpandedDraft] = useState(false)
|
||||
const voice = useVoiceInput()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
const isConversation = variant === 'conversation'
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const maxHeight = isConversation ? 176 : 100
|
||||
const collapsedHeight = isConversation ? 56 : 72
|
||||
element.style.height = '0px'
|
||||
const nextHeight = Math.min(element.scrollHeight, maxHeight)
|
||||
element.style.height = `${nextHeight}px`
|
||||
element.style.overflowY =
|
||||
element.scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||
setIsExpandedDraft(nextHeight > collapsedHeight)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
@@ -296,26 +321,43 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3',
|
||||
variant === 'home' ? 'px-4 py-3' : 'px-4 py-3',
|
||||
isExpandedDraft ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<BotInputIcon variant={variant} />
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={
|
||||
voice.isTranscribing
|
||||
? 'Transcribing...'
|
||||
: (placeholder ??
|
||||
`Message ${selectedAgent?.name ?? 'agent'}...`)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
voice.isTranscribing
|
||||
? 'Transcribing...'
|
||||
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
||||
/>
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className={cn(
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||
'[field-sizing:fixed]',
|
||||
variant === 'home'
|
||||
? 'min-h-[40px] py-2 leading-6'
|
||||
: 'min-h-[40px] py-2 leading-6',
|
||||
'placeholder:text-muted-foreground/80',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<VoiceButton
|
||||
isRecording={voice.isRecording}
|
||||
isTranscribing={voice.isTranscribing}
|
||||
@@ -361,8 +403,8 @@ function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
|
||||
className={cn(
|
||||
'flex items-center justify-center text-[var(--accent-orange)]',
|
||||
variant === 'home'
|
||||
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
|
||||
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
|
||||
? 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10'
|
||||
: 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
type AgentHistoryPageResponse,
|
||||
type BrowserOSChatHistoryItem,
|
||||
buildChatHistoryFromClawMessages,
|
||||
flattenHistoryPages,
|
||||
mapHistoryItemToClawMessage,
|
||||
} from './claw-chat-types'
|
||||
|
||||
function historyItem(
|
||||
overrides: Partial<BrowserOSChatHistoryItem>,
|
||||
): BrowserOSChatHistoryItem {
|
||||
return {
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'Hello',
|
||||
timestamp: 1000,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'session-1',
|
||||
source: 'user-chat',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: BrowserOSChatHistoryItem[]): AgentHistoryPageResponse {
|
||||
return {
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-1',
|
||||
session: null,
|
||||
items,
|
||||
page: {
|
||||
hasMore: false,
|
||||
limit: 50,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('claw-chat-types', () => {
|
||||
it('maps backend history items into text-first ClawChat messages', () => {
|
||||
const message = mapHistoryItemToClawMessage(
|
||||
historyItem({
|
||||
id: 'session-1:1',
|
||||
role: 'assistant',
|
||||
text: 'Hi there',
|
||||
messageSeq: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(message).toEqual({
|
||||
id: 'session-1:1',
|
||||
role: 'assistant',
|
||||
sessionKey: 'session-1',
|
||||
timestamp: 1000,
|
||||
source: 'user-chat',
|
||||
messageSeq: 1,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: 'Hi there' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('flattens paginated history into oldest-to-newest render order', () => {
|
||||
const messages = flattenHistoryPages([
|
||||
page([
|
||||
historyItem({
|
||||
id: 'session-1:2',
|
||||
role: 'user',
|
||||
text: 'newer',
|
||||
timestamp: 3000,
|
||||
messageSeq: 2,
|
||||
}),
|
||||
]),
|
||||
page([
|
||||
historyItem({
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'older',
|
||||
timestamp: 1000,
|
||||
messageSeq: 0,
|
||||
}),
|
||||
historyItem({
|
||||
id: 'session-1:1',
|
||||
role: 'assistant',
|
||||
text: 'middle',
|
||||
timestamp: 2000,
|
||||
messageSeq: 1,
|
||||
}),
|
||||
]),
|
||||
])
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
'session-1:0',
|
||||
'session-1:1',
|
||||
'session-1:2',
|
||||
])
|
||||
})
|
||||
|
||||
it('builds OpenClaw chat history from text message parts only', () => {
|
||||
const history = buildChatHistoryFromClawMessages([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
sessionKey: 'session-1',
|
||||
parts: [{ type: 'text', text: ' User request ' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
sessionKey: 'session-1',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'private reasoning' },
|
||||
{ type: 'text', text: 'Assistant answer' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', content: 'User request' },
|
||||
{ role: 'assistant', content: 'Assistant answer' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
|
||||
export type ClawChatRole = 'user' | 'assistant'
|
||||
|
||||
export type ClawChatSource = 'user-chat' | 'cron' | 'hook' | 'channel' | 'other'
|
||||
|
||||
export interface BrowserOSOpenClawSession {
|
||||
key: string
|
||||
updatedAt: number
|
||||
sessionId: string
|
||||
agentId: string
|
||||
kind: string
|
||||
source: ClawChatSource
|
||||
status?: string
|
||||
totalTokens?: number
|
||||
model?: string
|
||||
modelProvider?: string
|
||||
}
|
||||
|
||||
export interface AgentSessionResponse {
|
||||
agentId: string
|
||||
exists: boolean
|
||||
sessionKey: string | null
|
||||
session: BrowserOSOpenClawSession | null
|
||||
}
|
||||
|
||||
export interface BrowserOSChatHistoryItem {
|
||||
id: string
|
||||
role: ClawChatRole
|
||||
text: string
|
||||
timestamp?: number
|
||||
messageSeq: number
|
||||
sessionKey: string
|
||||
source: ClawChatSource
|
||||
}
|
||||
|
||||
export interface AgentHistoryPageResponse {
|
||||
agentId: string
|
||||
sessionKey: string | null
|
||||
session: BrowserOSOpenClawSession | null
|
||||
items: BrowserOSChatHistoryItem[]
|
||||
page: {
|
||||
cursor?: string
|
||||
hasMore: boolean
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ClawChatMessageStatus =
|
||||
| 'historical'
|
||||
| 'sending'
|
||||
| 'streaming'
|
||||
| 'error'
|
||||
|
||||
export type ClawChatMessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string; duration?: number }
|
||||
| {
|
||||
type: 'tool-call'
|
||||
name: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
}
|
||||
| { type: 'meta'; label: string; value: string }
|
||||
|
||||
export interface ClawChatMessage {
|
||||
id: string
|
||||
role: ClawChatRole
|
||||
sessionKey: string
|
||||
timestamp?: number
|
||||
source?: ClawChatSource
|
||||
messageSeq?: number
|
||||
status?: ClawChatMessageStatus
|
||||
parts: ClawChatMessagePart[]
|
||||
}
|
||||
|
||||
export function mapHistoryItemToClawMessage(
|
||||
item: BrowserOSChatHistoryItem,
|
||||
): ClawChatMessage {
|
||||
return {
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
sessionKey: item.sessionKey,
|
||||
timestamp: item.timestamp,
|
||||
source: item.source,
|
||||
messageSeq: item.messageSeq,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: item.text }],
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenHistoryPages(
|
||||
pages: AgentHistoryPageResponse[],
|
||||
): ClawChatMessage[] {
|
||||
return pages
|
||||
.flatMap((page) => page.items)
|
||||
.sort((a, b) => {
|
||||
if (a.timestamp != null && b.timestamp != null) {
|
||||
return a.timestamp - b.timestamp
|
||||
}
|
||||
return a.messageSeq - b.messageSeq
|
||||
})
|
||||
.map(mapHistoryItemToClawMessage)
|
||||
}
|
||||
|
||||
export function buildChatHistoryFromClawMessages(
|
||||
messages: ClawChatMessage[],
|
||||
): OpenClawChatHistoryMessage[] {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const content = message.parts
|
||||
.filter((part): part is { type: 'text'; text: string } => {
|
||||
return part.type === 'text' && part.text.trim().length > 0
|
||||
})
|
||||
.map((part) => part.text.trim())
|
||||
.join('\n\n')
|
||||
|
||||
return content ? { role: message.role, content } : null
|
||||
})
|
||||
.filter((message): message is OpenClawChatHistoryMessage =>
|
||||
Boolean(message),
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,45 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawChatHistoryMessage,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import {
|
||||
getLatestConversation,
|
||||
saveConversation,
|
||||
} from '@/lib/agent-conversations/storage'
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
|
||||
export function useAgentConversation(agentId: string, agentName: string) {
|
||||
interface UseAgentConversationOptions {
|
||||
sessionKey?: string | null
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
onSessionKeyChange?: (sessionKey: string) => void
|
||||
}
|
||||
|
||||
export function useAgentConversation(
|
||||
agentId: string,
|
||||
options: UseAgentConversationOptions = {},
|
||||
) {
|
||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const sessionKeyRef = useRef('')
|
||||
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
||||
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getLatestConversation(agentId)
|
||||
.then((conv) => {
|
||||
if (!active) return
|
||||
if (conv) {
|
||||
setTurns(conv.turns)
|
||||
sessionKeyRef.current = conv.sessionKey
|
||||
} else {
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agentId])
|
||||
sessionKeyRef.current = options.sessionKey ?? ''
|
||||
}, [options.sessionKey])
|
||||
|
||||
useEffect(() => {
|
||||
historyRef.current = options.history ?? []
|
||||
}, [options.history])
|
||||
|
||||
useEffect(() => {
|
||||
onSessionKeyChangeRef.current = options.onSessionKeyChange
|
||||
}, [options.onSessionKeyChange])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -54,18 +47,6 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
|
||||
const conv: AgentConversation = {
|
||||
agentId,
|
||||
agentName,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
turns: updatedTurns,
|
||||
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
saveConversation(conv).catch(() => {})
|
||||
}
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
@@ -165,9 +146,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
const updated = [...prev.slice(0, -1), { ...last, done: true }]
|
||||
persistTurns(updated)
|
||||
return updated
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -188,7 +167,6 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
|
||||
const send = async (text: string) => {
|
||||
if (!text.trim() || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: AgentConversationTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -208,10 +186,15 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
sessionKeyRef.current || undefined,
|
||||
historyRef.current,
|
||||
abortController.signal,
|
||||
)
|
||||
const responseSessionKey = response.headers.get('X-Session-Key')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
@@ -245,13 +228,11 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
streamAbortRef.current = null
|
||||
setTurns([])
|
||||
setStreaming(false)
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
}
|
||||
|
||||
return {
|
||||
turns,
|
||||
streaming,
|
||||
loading,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
send,
|
||||
resetConversation,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type {
|
||||
AgentHistoryPageResponse,
|
||||
AgentSessionResponse,
|
||||
} from './claw-chat-types'
|
||||
|
||||
export const CLAW_CHAT_QUERY_KEYS = {
|
||||
session: 'claw-agent-session',
|
||||
history: 'claw-agent-history',
|
||||
} as const
|
||||
|
||||
async function fetchClawJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed with status ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildClawUrl(baseUrl: string, path: string): URL {
|
||||
return new URL(`/claw${path}`, baseUrl)
|
||||
}
|
||||
|
||||
export function useClawAgentSession(agentId: string) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<AgentSessionResponse, Error>({
|
||||
queryKey: [CLAW_CHAT_QUERY_KEYS.session, baseUrl, agentId],
|
||||
queryFn: () => {
|
||||
const url = buildClawUrl(baseUrl as string, `/agents/${agentId}/session`)
|
||||
return fetchClawJson<AgentSessionResponse>(url.toString())
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
error: query.error ?? urlError,
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export function useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey,
|
||||
enabled,
|
||||
limit = 50,
|
||||
}: {
|
||||
agentId: string
|
||||
sessionKey: string | null
|
||||
enabled: boolean
|
||||
limit?: number
|
||||
}) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useInfiniteQuery<AgentHistoryPageResponse, Error>({
|
||||
queryKey: [CLAW_CHAT_QUERY_KEYS.history, baseUrl, agentId, sessionKey],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam }) => {
|
||||
const url = buildClawUrl(baseUrl as string, `/agents/${agentId}/history`)
|
||||
url.searchParams.set('limit', String(limit))
|
||||
|
||||
if (sessionKey) {
|
||||
url.searchParams.set('sessionKey', sessionKey)
|
||||
}
|
||||
if (typeof pageParam === 'string' && pageParam) {
|
||||
url.searchParams.set('cursor', pageParam)
|
||||
}
|
||||
|
||||
return fetchClawJson<AgentHistoryPageResponse>(url.toString())
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page.hasMore ? lastPage.page.cursor : undefined,
|
||||
enabled:
|
||||
enabled &&
|
||||
Boolean(baseUrl) &&
|
||||
!urlLoading &&
|
||||
Boolean(agentId) &&
|
||||
Boolean(sessionKey),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
error: query.error ?? urlError,
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type AssistantPart =
|
||||
| { kind: 'thinking'; text: string; done: boolean }
|
||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
interface ChatTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [turns])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamAbortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool: ToolEntry = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running',
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const status =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId
|
||||
? {
|
||||
...t,
|
||||
status: status as ToolEntry['status'],
|
||||
durationMs,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text,
|
||||
parts: [],
|
||||
done: false,
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h2 className="font-semibold text-lg">{agentName}</h2>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{turns.map((turn) => (
|
||||
<div key={turn.id} className="space-y-3">
|
||||
{/* User message */}
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Assistant response — all parts grouped */}
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={key}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when waiting for first part */}
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
className="min-h-[44px] resize-none"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || streaming}
|
||||
size="icon"
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,16 @@ import {
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { ArrowLeft, Check, Copy } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface AgentTerminalProps {
|
||||
onBack: () => void
|
||||
initialCommand?: string
|
||||
onSessionExit?: () => void
|
||||
}
|
||||
|
||||
type TerminalServerMessage =
|
||||
@@ -36,26 +38,22 @@ function resolveCssColor(variableName: string): string {
|
||||
return color
|
||||
}
|
||||
|
||||
function withAlpha(color: string, alpha: number): string {
|
||||
const channels = color.match(/[\d.]+/g)
|
||||
if (!channels || channels.length < 3) return color
|
||||
const [red, green, blue] = channels
|
||||
return `rgb(${red} ${green} ${blue} / ${alpha})`
|
||||
}
|
||||
|
||||
function createTerminalTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const background = resolveCssColor('--background')
|
||||
const foreground = resolveCssColor('--foreground')
|
||||
const muted = resolveCssColor('--muted-foreground')
|
||||
const accent = resolveCssColor('--accent-orange')
|
||||
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
|
||||
// Solid terminal-standard selection colors. Deriving from a CSS var
|
||||
// with alpha composed against the background produced near-white
|
||||
// rectangles on light mode, making selection invisible.
|
||||
selectionBackground: isDark ? '#3a4463' : '#b4d4f4',
|
||||
selectionInactiveBackground: isDark ? '#2b3348' : '#d9e5f3',
|
||||
selectionForeground: foreground,
|
||||
black: isDark ? '#16131a' : '#1f1b22',
|
||||
red: isDark ? '#ef8c7c' : '#c25544',
|
||||
@@ -118,8 +116,38 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
onBack,
|
||||
initialCommand,
|
||||
onSessionExit,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
// Refs keep the mount-once effect from tearing down the PTY when the
|
||||
// parent re-renders with new inline callbacks.
|
||||
const initialCommandRef = useRef(initialCommand)
|
||||
const onSessionExitRef = useRef(onSessionExit)
|
||||
initialCommandRef.current = initialCommand
|
||||
onSessionExitRef.current = onSessionExit
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Copy the current xterm selection to the browser clipboard. No-op
|
||||
// if nothing is selected — users who want the whole buffer can
|
||||
// Cmd+A first. Uses the browser clipboard, not the container's, so
|
||||
// it works even when the running TUI has mouse tracking enabled
|
||||
// (Opt+drag forces a selection regardless, see terminal config).
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
const text = terminalRef.current?.getSelection()
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// clipboard permission denied or unavailable — swallow, user will retry
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
@@ -132,6 +160,34 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
lineHeight: 1.25,
|
||||
scrollback: 8000,
|
||||
theme: createTerminalTheme(),
|
||||
// Opt+click+drag forces a native text selection even when the
|
||||
// running TUI has mouse-tracking enabled (xterm would otherwise
|
||||
// forward every click to the app and selection wouldn't work).
|
||||
macOptionClickForcesSelection: true,
|
||||
})
|
||||
terminalRef.current = terminal
|
||||
|
||||
// Cmd+A → select all, Cmd+C → copy selection via the browser
|
||||
// clipboard. Return false so xterm doesn't also forward the keys
|
||||
// to the running program.
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== 'keydown') return true
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const mod = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!mod) return true
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'a') {
|
||||
terminal.selectAll()
|
||||
return false
|
||||
}
|
||||
if (key === 'c') {
|
||||
const sel = terminal.getSelection()
|
||||
if (sel) {
|
||||
void navigator.clipboard.writeText(sel)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
@@ -139,6 +195,12 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
terminal.open(containerRef.current)
|
||||
|
||||
// React 18 StrictMode double-invokes effects in dev. Everything
|
||||
// async inside this effect is scoped to an AbortController; the
|
||||
// cleanup aborts it and any pending awaits bail out, so we never
|
||||
// leak a second live WebSocket or duplicate xterm listeners.
|
||||
const ac = new AbortController()
|
||||
const cleanups: Array<() => void> = []
|
||||
let ws: WebSocket | null = null
|
||||
let sawExit = false
|
||||
|
||||
@@ -159,17 +221,28 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
sendMessage({ type: 'resize', cols, rows })
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const connect = async (): Promise<void> => {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
if (ac.signal.aborted) return
|
||||
const wsUrl = new URL('/terminal/ws', baseUrl)
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
// If the effect was cleaned up between the await above and now,
|
||||
// close the socket we just opened and bail.
|
||||
if (ac.signal.aborted) {
|
||||
ws.close()
|
||||
ws = null
|
||||
return
|
||||
}
|
||||
cleanups.push(() => ws?.close())
|
||||
|
||||
ws.onopen = () => {
|
||||
fitAddon.fit()
|
||||
terminal.focus()
|
||||
sendResize()
|
||||
const cmd = initialCommandRef.current
|
||||
if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` })
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -185,6 +258,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
terminal.write(
|
||||
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
|
||||
)
|
||||
onSessionExitRef.current?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,49 +274,41 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
sendMessage({ type: 'input', data })
|
||||
})
|
||||
|
||||
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
||||
sendResize(cols, rows)
|
||||
})
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose()
|
||||
resizeDisposable.dispose()
|
||||
}
|
||||
cleanups.push(() => inputDisposable.dispose())
|
||||
cleanups.push(() => resizeDisposable.dispose())
|
||||
}
|
||||
|
||||
let disposeSocketBindings: (() => void) | undefined
|
||||
void connect().then((disposeBindings) => {
|
||||
disposeSocketBindings = disposeBindings
|
||||
})
|
||||
void connect()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit()
|
||||
sendResize()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
cleanups.push(() => resizeObserver.disconnect())
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
applyTheme()
|
||||
})
|
||||
const themeObserver = new MutationObserver(() => applyTheme())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
cleanups.push(() => themeObserver.disconnect())
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
disposeSocketBindings?.()
|
||||
ws?.close()
|
||||
ac.abort()
|
||||
for (const dispose of cleanups) dispose()
|
||||
terminal.dispose()
|
||||
terminalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-3 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
@@ -256,6 +322,14 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<Check className="mr-1 size-3.5" />
|
||||
) : (
|
||||
<Copy className="mr-1 size-3.5" />
|
||||
)}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-4 sm:p-6">
|
||||
@@ -269,7 +343,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="min-h-0 flex-1 cursor-text px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { CheckCircle2, Loader2, Terminal, TriangleAlert } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface OpenClawCliProvider {
|
||||
id: string
|
||||
displayName: string
|
||||
description: string
|
||||
models: readonly string[]
|
||||
authLoginCommand: string
|
||||
}
|
||||
|
||||
export interface OpenClawCliProviderAuthStatus {
|
||||
installed: boolean
|
||||
loggedIn: boolean
|
||||
accountLabel?: string
|
||||
subscriptionLabel?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OpenClawCliProviderOption {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
modelId: string
|
||||
}
|
||||
|
||||
const CLAUDE_CLI_PROVIDER: OpenClawCliProvider = {
|
||||
id: 'claude-cli',
|
||||
displayName: 'Anthropic Claude CLI',
|
||||
description: 'Uses your Claude.ai subscription via the Claude Code CLI',
|
||||
models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'],
|
||||
authLoginCommand: 'claude /login',
|
||||
}
|
||||
|
||||
export const OPENCLAW_CLI_PROVIDERS: readonly OpenClawCliProvider[] = [
|
||||
CLAUDE_CLI_PROVIDER,
|
||||
]
|
||||
|
||||
export function findOpenClawCliProviderById(
|
||||
id: string,
|
||||
): OpenClawCliProvider | undefined {
|
||||
return OPENCLAW_CLI_PROVIDERS.find((provider) => provider.id === id)
|
||||
}
|
||||
|
||||
export function buildOpenClawCliProviderOptions(): OpenClawCliProviderOption[] {
|
||||
return OPENCLAW_CLI_PROVIDERS.flatMap((provider) =>
|
||||
provider.models.map((modelId) => ({
|
||||
id: `${provider.id}/${modelId}`,
|
||||
type: provider.id,
|
||||
name: provider.displayName,
|
||||
modelId,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchCliProviderAuthStatus(
|
||||
baseUrl: string,
|
||||
providerId: string,
|
||||
): Promise<OpenClawCliProviderAuthStatus> {
|
||||
const res = await fetch(`${baseUrl}/claw/providers/${providerId}/auth-status`)
|
||||
if (!res.ok) {
|
||||
let message = `Auth status request failed (${res.status})`
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<OpenClawCliProviderAuthStatus>
|
||||
}
|
||||
|
||||
export function useOpenClawCliProviderAuthStatus(
|
||||
providerId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
return useQuery<OpenClawCliProviderAuthStatus, Error>({
|
||||
queryKey: ['openclaw-cli-auth', baseUrl, providerId],
|
||||
queryFn: () => fetchCliProviderAuthStatus(baseUrl as string, providerId),
|
||||
enabled: !!baseUrl && !urlLoading && enabled,
|
||||
refetchInterval: enabled ? 2000 : false,
|
||||
})
|
||||
}
|
||||
|
||||
interface OpenClawCliProviderStatusPanelProps {
|
||||
provider: OpenClawCliProvider
|
||||
status: OpenClawCliProviderAuthStatus | undefined
|
||||
loading: boolean
|
||||
fetchError: Error | null
|
||||
onConnect: () => void
|
||||
}
|
||||
|
||||
export const OpenClawCliProviderStatusPanel: FC<
|
||||
OpenClawCliProviderStatusPanelProps
|
||||
> = ({ provider, status, loading, fetchError, onConnect }) => {
|
||||
// Initial fetch (no data yet).
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Checking {provider.displayName} status…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
|
||||
<TriangleAlert className="mt-0.5 size-4 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">
|
||||
Could not read {provider.displayName} status
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{fetchError.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) return null
|
||||
|
||||
// Install failed or binary missing.
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm">
|
||||
<TriangleAlert className="mt-0.5 size-4 text-amber-600" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{provider.displayName} not installed
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
The gateway will try to install it on the next restart. If this
|
||||
persists, check your network and the gateway logs.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Happy path.
|
||||
if (status.loggedIn) {
|
||||
const identityBits = [
|
||||
status.accountLabel,
|
||||
status.subscriptionLabel ? `(${status.subscriptionLabel})` : null,
|
||||
].filter(Boolean)
|
||||
const identity = identityBits.length > 0 ? identityBits.join(' ') : 'Ready'
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/5 px-3 py-2 text-sm">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">Connected to {provider.displayName}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{identity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Installed but not logged in.
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 px-3 py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{provider.displayName} not set up</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{provider.description}
|
||||
</div>
|
||||
{status.error && (
|
||||
<div className="mt-1 text-destructive text-xs">{status.error}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onConnect} className="w-fit">
|
||||
<Terminal className="mr-1 size-4" />
|
||||
Connect {provider.displayName}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -59,14 +59,8 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
podmanOverrides: 'openclaw-podman-overrides',
|
||||
} as const
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
export type GatewayLifecycleAction =
|
||||
| 'setup'
|
||||
| 'start'
|
||||
@@ -262,50 +256,6 @@ export function useOpenClawMutations() {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePodmanOverrides() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useQuery<PodmanOverrides, Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
|
||||
queryFn: () =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (podmanPath: string | null) =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath }),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.status],
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
overrides: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: (query.error ?? urlError) as Error | null,
|
||||
saving: saveMutation.isPending,
|
||||
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
|
||||
clearOverrides: () => saveMutation.mutateAsync(null),
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
|
||||
@@ -18,8 +18,8 @@ describe('route-utils', () => {
|
||||
expect(shouldUseChatSession('/home/chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the focus grid on home while hiding it on dedicated full-screen routes', () => {
|
||||
expect(shouldHideFocusGrid('/home')).toBe(false)
|
||||
it('hides the focus grid on full-screen routes', () => {
|
||||
expect(shouldHideFocusGrid('/home')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home',
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
|
||||
@@ -7,6 +7,11 @@ BROWSEROS_EXTENSION_PORT=9300
|
||||
# BROWSEROS_RESOURCES_DIR=./resources
|
||||
# BROWSEROS_EXECUTION_DIR=./out
|
||||
|
||||
# VM cache (optional - runtime downloads published agent cache in background)
|
||||
# Set prefetch=false to skip startup warmup; VM/OpenClaw startup still syncs on demand.
|
||||
BROWSEROS_VM_CACHE_PREFETCH=true
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
|
||||
|
||||
# BrowserOS config
|
||||
BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config
|
||||
BROWSEROS_VERSION=
|
||||
|
||||
@@ -5,6 +5,9 @@ CODEGEN_SERVICE_URL=
|
||||
POSTHOG_API_KEY=
|
||||
SENTRY_DSN=
|
||||
|
||||
BROWSEROS_VM_CACHE_PREFETCH=true
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
|
||||
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -142,7 +142,7 @@ cp .env.example .env.development
|
||||
bun run start
|
||||
```
|
||||
|
||||
See the [agent monorepo README](../../README.md) for full environment variable reference and `process-compose` setup.
|
||||
See the [agent monorepo README](../../README.md) for full environment variable reference and `dev:watch` setup.
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.88",
|
||||
"version": "0.0.92",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -45,13 +45,8 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
c.req.query('agentId') ??
|
||||
c.req.header('X-BrowserOS-Agent-Id') ??
|
||||
undefined
|
||||
const activeSession = explicitAgentId
|
||||
? {
|
||||
agentId: explicitAgentId,
|
||||
monitoringSessionId:
|
||||
monitoringService.getActiveSessionId(explicitAgentId),
|
||||
}
|
||||
: monitoringService.getSingleActiveSession()
|
||||
const activeSession =
|
||||
monitoringService.resolveSessionForMcpRequest(explicitAgentId)
|
||||
const agentId = activeSession?.agentId
|
||||
metrics.log('mcp.request', { scopeId })
|
||||
const aclRules = await resolveAclPolicyForMcpRequest({
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
@@ -19,9 +17,14 @@ import {
|
||||
OpenClawAgentNotFoundError,
|
||||
OpenClawInvalidAgentNameError,
|
||||
OpenClawProtectedAgentError,
|
||||
OpenClawSessionNotFoundError,
|
||||
} from '../services/openclaw/errors'
|
||||
import { getOpenClawCliProvider } from '../services/openclaw/openclaw-cli-providers/registry'
|
||||
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
|
||||
import { getOpenClawService } from '../services/openclaw/openclaw-service'
|
||||
import {
|
||||
getOpenClawService,
|
||||
normalizeBrowserOSChatSessionKey,
|
||||
} from '../services/openclaw/openclaw-service'
|
||||
|
||||
function getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
if (!body.name?.trim()) {
|
||||
@@ -30,25 +33,14 @@ function getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function getPodmanOverrideValidationError(body: {
|
||||
podmanPath?: string | null
|
||||
}): string | null {
|
||||
if (body.podmanPath === null) return null
|
||||
if (typeof body.podmanPath !== 'string' || !body.podmanPath.trim()) {
|
||||
return 'podmanPath must be a non-empty absolute path or null'
|
||||
}
|
||||
if (!path.isAbsolute(body.podmanPath)) {
|
||||
return 'podmanPath must be an absolute path'
|
||||
}
|
||||
if (!existsSync(body.podmanPath)) {
|
||||
return `File does not exist: ${body.podmanPath}`
|
||||
}
|
||||
try {
|
||||
accessSync(body.podmanPath, fsConstants.X_OK)
|
||||
} catch {
|
||||
return `File is not executable: ${body.podmanPath}`
|
||||
}
|
||||
return null
|
||||
function parsePositiveIntQuery(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (value === undefined) return fallback
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.max(1, Math.trunc(parsed))
|
||||
}
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
@@ -58,6 +50,29 @@ export function createOpenClawRoutes() {
|
||||
return c.json(status)
|
||||
})
|
||||
|
||||
.get('/providers/:providerId/auth-status', async (c) => {
|
||||
const { providerId } = c.req.param()
|
||||
const provider = getOpenClawCliProvider(providerId)
|
||||
if (!provider) {
|
||||
return c.json({ error: `Unknown CLI provider: ${providerId}` }, 404)
|
||||
}
|
||||
try {
|
||||
const status =
|
||||
await getOpenClawService().getCliProviderAuthStatus(provider)
|
||||
return c.json(status)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.warn('CLI provider auth-status failed', {
|
||||
providerId,
|
||||
error: message,
|
||||
})
|
||||
return c.json(
|
||||
{ installed: false, loggedIn: false, error: message },
|
||||
500,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/setup', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
providerType?: string
|
||||
@@ -102,7 +117,7 @@ export function createOpenClawRoutes() {
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (message.includes('Podman is not available')) {
|
||||
if (message.includes('VM runtime is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
@@ -224,6 +239,51 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/sessions', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const limit = parsePositiveIntQuery(c.req.query('limit'), 20)
|
||||
|
||||
try {
|
||||
const sessions = await getOpenClawService().listSessions(id)
|
||||
return c.json({
|
||||
agentId: id,
|
||||
sessions: sessions.slice(0, Math.min(limit, 100)),
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/session', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
|
||||
try {
|
||||
const session = await getOpenClawService().resolveAgentSession(id)
|
||||
return c.json(session)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/history', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const limit = parsePositiveIntQuery(c.req.query('limit'), 50)
|
||||
|
||||
try {
|
||||
const page = await getOpenClawService().getAgentHistoryPage(id, {
|
||||
sessionKey: c.req.query('sessionKey'),
|
||||
cursor: c.req.query('cursor'),
|
||||
limit,
|
||||
})
|
||||
return c.json(page)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/agents/:id/chat', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const body = await c.req.json<{
|
||||
@@ -236,7 +296,10 @@ export function createOpenClawRoutes() {
|
||||
return c.json({ error: 'Message is required' }, 400)
|
||||
}
|
||||
|
||||
const sessionKey = body.sessionKey ?? crypto.randomUUID()
|
||||
const sessionKey = normalizeBrowserOSChatSessionKey(
|
||||
id,
|
||||
body.sessionKey ?? crypto.randomUUID(),
|
||||
)
|
||||
const history = Array.isArray(body.history)
|
||||
? body.history.filter((entry): entry is MonitoringChatTurn =>
|
||||
Boolean(
|
||||
@@ -344,6 +407,61 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
.get('/session/:key/history', async (c) => {
|
||||
const key = c.req.param('key')
|
||||
const limitRaw = c.req.query('limit')
|
||||
const cursor = c.req.query('cursor')
|
||||
const limitParsed =
|
||||
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : Number.NaN
|
||||
const limit = Number.isFinite(limitParsed) ? limitParsed : undefined
|
||||
const wantsStream = (c.req.header('accept') ?? '').includes(
|
||||
'text/event-stream',
|
||||
)
|
||||
|
||||
try {
|
||||
if (!wantsStream) {
|
||||
const history = await getOpenClawService().getSessionHistory(key, {
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
return c.json(history)
|
||||
}
|
||||
|
||||
const eventStream = await getOpenClawService().streamSessionHistory(
|
||||
key,
|
||||
{ limit, cursor, signal: c.req.raw.signal },
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Key', key)
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(
|
||||
encoder.encode(
|
||||
`event: ${value.type}\ndata: ${JSON.stringify(value.data)}\n\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof OpenClawSessionNotFoundError) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/logs', async (c) => {
|
||||
try {
|
||||
const logs = await getOpenClawService().getLogs()
|
||||
@@ -383,37 +501,4 @@ export function createOpenClawRoutes() {
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/podman-overrides', async (c) => {
|
||||
try {
|
||||
const overrides = await getOpenClawService().getPodmanOverrides()
|
||||
return c.json(overrides)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Podman overrides read failed', { error: message })
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/podman-overrides', async (c) => {
|
||||
const body = await c.req.json<{ podmanPath: string | null }>()
|
||||
const validationError = getPodmanOverrideValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('OpenClaw podman override requested', {
|
||||
podmanPath: body.podmanPath,
|
||||
})
|
||||
const result = await getOpenClawService().applyPodmanOverrides({
|
||||
podmanPath: body.podmanPath,
|
||||
})
|
||||
return c.json(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Podman overrides apply failed', { error: message })
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export const TERMINAL_WS_PATH = '/terminal/ws'
|
||||
|
||||
interface TerminalRouteDeps {
|
||||
containerName: string
|
||||
podmanPath: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
}
|
||||
|
||||
function safeSend(ws: { send(data: string): void }, data: string): void {
|
||||
@@ -45,7 +47,9 @@ function createSocketEvents(deps: TerminalRouteDeps) {
|
||||
try {
|
||||
session = createTerminalSession({
|
||||
containerName: deps.containerName,
|
||||
podmanPath: deps.podmanPath,
|
||||
limaHome: deps.limaHome,
|
||||
limactlPath: deps.limactlPath,
|
||||
vmName: deps.vmName,
|
||||
workingDir: TERMINAL_HOME_DIR,
|
||||
onOutput(data) {
|
||||
sendOutput(ws, data)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
|
||||
import { createAclRoutes } from './routes/acl'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
import { requireTrustedAppOrigin } from './utils/request-auth'
|
||||
@@ -114,7 +114,9 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
'/',
|
||||
createTerminalRoutes({
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
podmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
limaHome: getLimaHomeDir(),
|
||||
limactlPath: resolveBundledLimactl(resourcesDir),
|
||||
vmName: VM_NAME,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import {
|
||||
buildMonitoringToolOutput,
|
||||
type ToolExecutionObserver,
|
||||
} from '../../../monitoring/observer'
|
||||
import { klavisStrataCache } from './strata-cache'
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
@@ -256,6 +259,8 @@ export function registerKlavisTools(
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: 'connector_mcp_servers',
|
||||
toolDescription:
|
||||
'Check whether an external connector is connected and ready for use.',
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
@@ -375,6 +380,7 @@ export function registerKlavisTools(
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
toolDescription: tool.description ?? undefined,
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
@@ -389,7 +395,7 @@ export function registerKlavisTools(
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: result,
|
||||
output: buildMonitoringToolOutput(result),
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import {
|
||||
buildMonitoringToolOutput,
|
||||
type ToolExecutionObserver,
|
||||
} from '../../../monitoring/observer'
|
||||
import { executeTool, type ToolContext } from '../../../tools/framework'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
|
||||
@@ -23,6 +26,7 @@ export function registerTools(
|
||||
await ctx.observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
toolDescription: tool.description,
|
||||
source: 'browser-tool',
|
||||
args,
|
||||
})
|
||||
@@ -38,7 +42,12 @@ export function registerTools(
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: result.structuredContent ?? result.content,
|
||||
output: buildMonitoringToolOutput({
|
||||
content: result.content,
|
||||
structuredContent: result.structuredContent,
|
||||
metadata: result.metadata,
|
||||
isError: result.isError,
|
||||
}),
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @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 {
|
||||
detectArch,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import {
|
||||
ensureVmCacheAvailable,
|
||||
ensureVmCacheSynced,
|
||||
type VmCacheSyncOptions,
|
||||
} from '../../../lib/vm/cache-sync'
|
||||
import { readCachedManifest } from '../../../lib/vm/manifest'
|
||||
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
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}
|
||||
|
||||
export interface VmCacheRuntimeConfig
|
||||
extends Pick<VmCacheSyncOptions, 'manifestUrl'> {
|
||||
ensureAvailable?: () => Promise<void>
|
||||
ensureSynced?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export function buildContainerRuntime(
|
||||
input: ContainerRuntimeFactoryInput,
|
||||
): ContainerRuntime {
|
||||
const platform = input.platform ?? process.platform
|
||||
if (platform !== 'darwin') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
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,
|
||||
ensureCacheAvailable:
|
||||
input.vmCache?.ensureAvailable ??
|
||||
(() =>
|
||||
ensureVmCacheAvailable({
|
||||
browserosRoot,
|
||||
manifestUrl: input.vmCache?.manifestUrl,
|
||||
})),
|
||||
})
|
||||
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new DeferredImageLoader(shell, browserosRoot, input.vmCache)
|
||||
|
||||
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 DeferredImageLoader {
|
||||
constructor(
|
||||
private readonly shell: ContainerCli,
|
||||
private readonly browserosRoot: string,
|
||||
private readonly vmCache?: VmCacheRuntimeConfig,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
|
||||
await this.ensureCacheSynced()
|
||||
const manifest = await readCachedManifest(this.browserosRoot)
|
||||
const loader = new ImageLoader(
|
||||
this.shell,
|
||||
manifest,
|
||||
detectArch(),
|
||||
this.browserosRoot,
|
||||
)
|
||||
await loader.ensureImageLoaded(ref, onLog)
|
||||
}
|
||||
|
||||
private async ensureCacheSynced(): Promise<void> {
|
||||
if (this.vmCache?.ensureSynced) {
|
||||
await this.vmCache.ensureSynced()
|
||||
return
|
||||
}
|
||||
await ensureVmCacheSynced({
|
||||
browserosRoot: this.browserosRoot,
|
||||
manifestUrl: this.vmCache?.manifestUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
|
||||
constructor(projectDir: string) {
|
||||
super({
|
||||
vm: {} as VmRuntime,
|
||||
shell: {} as ContainerCli,
|
||||
loader: { ensureImageLoaded: 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 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)
|
||||
}
|
||||
@@ -2,19 +2,41 @@
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* OpenClaw container lifecycle abstraction over PodmanRuntime.
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import type {
|
||||
ContainerCli,
|
||||
ContainerCommandResult,
|
||||
ContainerSpec,
|
||||
LogFn,
|
||||
} from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
hostPathToGuest,
|
||||
type VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
|
||||
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`
|
||||
// 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 = {
|
||||
image: string
|
||||
@@ -25,78 +47,63 @@ export type GatewayContainerSpec = {
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface ContainerRuntimeConfig {
|
||||
vm: VmRuntime
|
||||
shell: ContainerCli
|
||||
loader: { ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> }
|
||||
projectDir: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
private podman: PodmanRuntime,
|
||||
private projectDir: string,
|
||||
) {}
|
||||
private readonly vm: VmRuntime
|
||||
private readonly shell: ContainerCli
|
||||
private readonly loader: {
|
||||
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
|
||||
}
|
||||
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 Podman runtime readiness')
|
||||
return this.podman.ensureReady(onLog)
|
||||
logger.info('Ensuring BrowserOS VM runtime readiness')
|
||||
await this.vm.ensureReady(onLog)
|
||||
await this.vm.getDefaultGateway()
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return this.podman.isPodmanAvailable()
|
||||
return true
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return this.podman.getMachineStatus()
|
||||
const running = await this.vm.isReady()
|
||||
return { initialized: running, running }
|
||||
}
|
||||
|
||||
async pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
const code = await this.runPodmanCommand(['pull', image], onLog)
|
||||
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
|
||||
await this.loader.ensureImageLoaded(image, onLog)
|
||||
}
|
||||
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.ensureGatewayRemoved(onLog)
|
||||
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
const code = await this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${input.hostPort}:${containerPort}`,
|
||||
...this.buildGatewayContainerRuntimeArgs(input),
|
||||
'--health-cmd',
|
||||
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
input.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
containerPort,
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
|
||||
await this.removeGatewayContainer(onLog)
|
||||
await this.loader.ensureImageLoaded(input.image, onLog)
|
||||
const container = await this.buildGatewayContainerSpec(input)
|
||||
await this.shell.createContainer(container, onLog)
|
||||
await this.shell.startContainer(container.name)
|
||||
}
|
||||
|
||||
async stopGateway(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.removeGatewayContainer(onLog)
|
||||
if (code !== 0) {
|
||||
throw new Error(`gateway stop failed with code ${code}`)
|
||||
}
|
||||
await this.removeGatewayContainer(onLog)
|
||||
}
|
||||
|
||||
async restartGateway(
|
||||
@@ -108,8 +115,8 @@ export class ContainerRuntime {
|
||||
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.runPodmanCommand(
|
||||
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
await this.shell.runCommand(
|
||||
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(line) => lines.push(line),
|
||||
)
|
||||
return lines
|
||||
@@ -140,13 +147,7 @@ export class ContainerRuntime {
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(hostPort)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
hostPort,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (await this.isReady(hostPort)) return true
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
@@ -156,35 +157,23 @@ export class ContainerRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
*/
|
||||
async stopMachineIfSafe(): Promise<void> {
|
||||
const status = await this.podman.getMachineStatus()
|
||||
if (!status.running) return
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every(
|
||||
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
await this.podman.stopMachine()
|
||||
}
|
||||
} catch {
|
||||
// Best effort — don't stop machine if we can't check
|
||||
}
|
||||
async stopVm(): Promise<void> {
|
||||
await this.vm.stopVm()
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.podman.runCommand(
|
||||
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
|
||||
{
|
||||
onOutput: onLog,
|
||||
},
|
||||
)
|
||||
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(
|
||||
@@ -193,103 +182,136 @@ export class ContainerRuntime {
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
|
||||
await this.loader.ensureImageLoaded(spec.image, onLog)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
return this.runPodmanCommand(
|
||||
const createResult = await this.shell.runCommand(
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'create',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...this.buildGatewayContainerRuntimeArgs(spec),
|
||||
...(await this.buildGatewayRunArgs(spec)),
|
||||
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.podman.tailContainerLogs(
|
||||
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
await this.shell.removeContainer(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
onLine,
|
||||
)
|
||||
}
|
||||
|
||||
private async runPodmanCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const command = ['podman', ...args].join(' ')
|
||||
logger.info('Running OpenClaw podman command', {
|
||||
command,
|
||||
})
|
||||
const code = await this.podman.runCommand(args, {
|
||||
cwd: this.projectDir,
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw podman command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
logger.info('OpenClaw podman command succeeded', {
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
|
||||
return this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
private buildGatewayContainerRuntimeArgs(
|
||||
private async buildGatewayContainerSpec(
|
||||
input: GatewayContainerSpec,
|
||||
): string[] {
|
||||
return [
|
||||
): Promise<ContainerSpec> {
|
||||
return {
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: input.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',
|
||||
input.envFilePath,
|
||||
'-e',
|
||||
`HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${input.timezone}`,
|
||||
this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
'-v',
|
||||
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
...(input.gatewayToken
|
||||
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
|
||||
: []),
|
||||
`${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 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,
|
||||
...(input.gatewayToken
|
||||
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,10 @@ export class OpenClawProtectedAgentError extends Error {
|
||||
this.name = 'OpenClawProtectedAgentError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawSessionNotFoundError extends Error {
|
||||
constructor(public readonly sessionKey: string) {
|
||||
super(`OpenClaw session not found: ${sessionKey}`)
|
||||
this.name = 'OpenClawSessionNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,37 @@ export interface OpenClawAgentRecord {
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface OpenClawSessionEntry {
|
||||
key: string
|
||||
updatedAt: number
|
||||
sessionId: string
|
||||
agentId: string
|
||||
kind: string
|
||||
status?: string
|
||||
totalTokens?: number
|
||||
model?: string
|
||||
modelProvider?: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatBlock {
|
||||
type: 'text' | 'toolCall' | 'thinking'
|
||||
text?: string
|
||||
name?: string
|
||||
arguments?: unknown
|
||||
thinking?: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatMessage {
|
||||
role: 'user' | 'assistant' | 'toolResult'
|
||||
content: OpenClawChatBlock[]
|
||||
timestamp?: number
|
||||
usage?: { input: number; output: number }
|
||||
stopReason?: string
|
||||
toolName?: string
|
||||
toolCallId?: string
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export class OpenClawCliClient {
|
||||
constructor(private readonly executor: ContainerExecutor) {}
|
||||
|
||||
@@ -191,6 +222,53 @@ export class OpenClawCliClient {
|
||||
await this.listAgents()
|
||||
}
|
||||
|
||||
async listSessions(agentId?: string): Promise<OpenClawSessionEntry[]> {
|
||||
const args = ['sessions', '--json']
|
||||
if (agentId) {
|
||||
args.push('--agent', agentId)
|
||||
} else {
|
||||
args.push('--all-agents')
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args)
|
||||
const parsed = parseFirstMatchingJson<
|
||||
{ sessions?: unknown[]; count?: number } | unknown[]
|
||||
>(output, isSessionListPayload)
|
||||
|
||||
if (parsed === null) {
|
||||
throw new Error(
|
||||
`Failed to parse OpenClaw sessions output: ${output.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const entries = Array.isArray(parsed) ? parsed : (parsed.sessions ?? [])
|
||||
return entries.map(toSessionEntry)
|
||||
}
|
||||
|
||||
async getChatHistory(sessionKey: string): Promise<OpenClawChatMessage[]> {
|
||||
const output = await this.runCommand([
|
||||
'gateway',
|
||||
'call',
|
||||
'chat.history',
|
||||
'--params',
|
||||
JSON.stringify({ sessionKey }),
|
||||
'--json',
|
||||
])
|
||||
|
||||
const parsed = parseFirstMatchingJson<{ messages?: unknown[] }>(
|
||||
output,
|
||||
(value) => isPlainObject(value) && 'messages' in value,
|
||||
)
|
||||
|
||||
if (parsed === null) {
|
||||
throw new Error(
|
||||
`Failed to parse OpenClaw chat history output: ${output.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (parsed.messages ?? []).map(toChatMessage)
|
||||
}
|
||||
|
||||
private agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${OPENCLAW_CONTAINER_HOME}/workspace`
|
||||
@@ -405,3 +483,99 @@ function isStructuredLogPayload(value: unknown): boolean {
|
||||
(typeof value.message === 'string' || typeof value.msg === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isSessionListPayload(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return true
|
||||
if (!isPlainObject(value)) return false
|
||||
return 'sessions' in value || 'count' in value
|
||||
}
|
||||
|
||||
function toSessionEntry(raw: unknown): OpenClawSessionEntry {
|
||||
const record = isPlainObject(raw) ? raw : {}
|
||||
return {
|
||||
key: String(record.key ?? ''),
|
||||
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
|
||||
sessionId: String(record.sessionId ?? ''),
|
||||
agentId: String(record.agentId ?? ''),
|
||||
kind: String(record.kind ?? ''),
|
||||
status: typeof record.status === 'string' ? record.status : undefined,
|
||||
totalTokens:
|
||||
typeof record.totalTokens === 'number' ? record.totalTokens : undefined,
|
||||
model: typeof record.model === 'string' ? record.model : undefined,
|
||||
modelProvider:
|
||||
typeof record.modelProvider === 'string'
|
||||
? record.modelProvider
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toChatMessage(raw: unknown): OpenClawChatMessage {
|
||||
const record = isPlainObject(raw) ? raw : {}
|
||||
const role = isOpenClawMessageRole(record.role) ? record.role : 'assistant'
|
||||
const message: OpenClawChatMessage = {
|
||||
role,
|
||||
content: toChatBlocks(record.content),
|
||||
}
|
||||
|
||||
if (typeof record.timestamp === 'number') message.timestamp = record.timestamp
|
||||
if (isPlainObject(record.usage)) {
|
||||
const { input, output } = record.usage
|
||||
if (typeof input === 'number' && typeof output === 'number') {
|
||||
message.usage = { input, output }
|
||||
}
|
||||
}
|
||||
if (typeof record.stopReason === 'string') {
|
||||
message.stopReason = record.stopReason
|
||||
}
|
||||
if (typeof record.toolName === 'string') message.toolName = record.toolName
|
||||
if (typeof record.toolCallId === 'string') {
|
||||
message.toolCallId = record.toolCallId
|
||||
}
|
||||
if (typeof record.isError === 'boolean') message.isError = record.isError
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
function toChatBlocks(content: unknown): OpenClawChatBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
return [{ type: 'text', text: content }]
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return []
|
||||
|
||||
const blocks: OpenClawChatBlock[] = []
|
||||
for (const rawBlock of content) {
|
||||
if (!isPlainObject(rawBlock)) continue
|
||||
|
||||
if (rawBlock.type === 'toolCall') {
|
||||
const block: OpenClawChatBlock = { type: 'toolCall' }
|
||||
if (typeof rawBlock.name === 'string') block.name = rawBlock.name
|
||||
if (rawBlock.arguments !== undefined) {
|
||||
block.arguments = rawBlock.arguments
|
||||
}
|
||||
blocks.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
if (rawBlock.type === 'thinking') {
|
||||
const block: OpenClawChatBlock = { type: 'thinking' }
|
||||
if (typeof rawBlock.thinking === 'string') {
|
||||
block.thinking = rawBlock.thinking
|
||||
}
|
||||
blocks.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
const block: OpenClawChatBlock = { type: 'text' }
|
||||
if (typeof rawBlock.text === 'string') block.text = rawBlock.text
|
||||
blocks.push(block)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function isOpenClawMessageRole(
|
||||
value: unknown,
|
||||
): value is OpenClawChatMessage['role'] {
|
||||
return value === 'user' || value === 'assistant' || value === 'toolResult'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
OpenClawCliProvider,
|
||||
OpenClawCliProviderAuthStatus,
|
||||
} from './types'
|
||||
|
||||
const CLAUDE_CLI_MODELS = [
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6',
|
||||
'claude-haiku-4-5',
|
||||
] as const
|
||||
|
||||
// `claude auth status` emits JSON on both the logged-in (exit 0) and
|
||||
// not-logged-in (exit 1) paths. The caller passes us stdout alone —
|
||||
// the exec layer separates stdout and stderr so no extraction or
|
||||
// stripping of nerdctl noise is needed.
|
||||
interface ClaudeAuthStatusPayload {
|
||||
loggedIn?: boolean
|
||||
email?: string
|
||||
subscriptionType?: string
|
||||
}
|
||||
|
||||
function parseClaudeAuthStatus(
|
||||
stdout: string,
|
||||
exitCode: number,
|
||||
): OpenClawCliProviderAuthStatus {
|
||||
const trimmed = stdout.trim()
|
||||
|
||||
// Binary missing: claude isn't installed / not on PATH.
|
||||
if (exitCode === 127 || !trimmed) {
|
||||
return { installed: false, loggedIn: false }
|
||||
}
|
||||
|
||||
let payload: ClaudeAuthStatusPayload
|
||||
try {
|
||||
payload = JSON.parse(trimmed) as ClaudeAuthStatusPayload
|
||||
} catch {
|
||||
return {
|
||||
installed: true,
|
||||
loggedIn: false,
|
||||
error: `Unexpected claude auth status output: ${trimmed.slice(0, 200)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
loggedIn: !!payload.loggedIn,
|
||||
accountLabel: payload.email,
|
||||
subscriptionLabel: payload.subscriptionType,
|
||||
}
|
||||
}
|
||||
|
||||
export const CLAUDE_CLI_PROVIDER: OpenClawCliProvider = {
|
||||
id: 'claude-cli',
|
||||
displayName: 'Anthropic Claude CLI',
|
||||
description: 'Uses your Claude.ai subscription via the Claude Code CLI',
|
||||
npmPackage: '@anthropic-ai/claude-code',
|
||||
npmPackageVersion: '2.1.119',
|
||||
binary: 'claude',
|
||||
authStatusCommand: ['claude', 'auth', 'status'],
|
||||
// `claude auth login` in 2.1.x silently discards stdin. The REPL's
|
||||
// `/login` slash command, launched from a fresh `claude` invocation,
|
||||
// does accept a pasted token.
|
||||
authLoginCommand: 'claude /login',
|
||||
models: CLAUDE_CLI_MODELS,
|
||||
parseAuthStatus: parseClaudeAuthStatus,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Registry of OpenClaw CLI-backed providers. Add entries here as we
|
||||
* enable more (Gemini CLI, Codex CLI, etc.).
|
||||
*/
|
||||
|
||||
import { CLAUDE_CLI_PROVIDER } from './claude-cli'
|
||||
import type { OpenClawCliProvider } from './types'
|
||||
|
||||
export const OPENCLAW_CLI_PROVIDERS: readonly OpenClawCliProvider[] = [
|
||||
CLAUDE_CLI_PROVIDER,
|
||||
]
|
||||
|
||||
export function getOpenClawCliProvider(
|
||||
id: string,
|
||||
): OpenClawCliProvider | undefined {
|
||||
return OPENCLAW_CLI_PROVIDERS.find((provider) => provider.id === id)
|
||||
}
|
||||
|
||||
export function isOpenClawCliProviderId(id: string): boolean {
|
||||
return OPENCLAW_CLI_PROVIDERS.some((provider) => provider.id === id)
|
||||
}
|
||||
|
||||
export function buildOpenClawCliProviderModelRef(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
): string {
|
||||
return `${providerId}/${modelId}`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* OpenClaw CLI-backed provider registry types.
|
||||
*
|
||||
* A "CLI provider" is a tool that runs inside the OpenClaw gateway
|
||||
* container (e.g. Claude Code CLI, Gemini CLI). OpenClaw spawns the
|
||||
* binary as a subprocess when the active model is prefixed with the
|
||||
* provider id — so our job is to install the tool and surface its
|
||||
* auth status to the user. No Anthropic/OpenRouter-style API key.
|
||||
*/
|
||||
|
||||
export interface OpenClawCliProviderAuthStatus {
|
||||
installed: boolean
|
||||
loggedIn: boolean
|
||||
accountLabel?: string
|
||||
subscriptionLabel?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OpenClawCliProvider {
|
||||
id: string
|
||||
displayName: string
|
||||
description: string
|
||||
npmPackage: string
|
||||
// Pinned package version. npm installs go through argv directly
|
||||
// (no shell), so `@latest` drift can't silently ship through.
|
||||
npmPackageVersion: string
|
||||
binary: string
|
||||
authStatusCommand: string[]
|
||||
authLoginCommand: string
|
||||
models: readonly string[]
|
||||
parseAuthStatus: (
|
||||
stdout: string,
|
||||
exitCode: number,
|
||||
) => OpenClawCliProviderAuthStatus
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import { OpenClawSessionNotFoundError } from './errors'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
@@ -20,7 +21,42 @@ export interface OpenClawChatRequest {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawHttpChatClient {
|
||||
export interface OpenClawSessionHistoryMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string
|
||||
messageId?: string
|
||||
messageSeq?: number
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistory {
|
||||
sessionKey: string
|
||||
messages: OpenClawSessionHistoryMessage[]
|
||||
cursor?: string | null
|
||||
hasMore?: boolean
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistoryInput {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type OpenClawSessionHistoryEvent =
|
||||
| { type: 'history'; data: OpenClawSessionHistory }
|
||||
| {
|
||||
type: 'message'
|
||||
data: {
|
||||
sessionKey: string
|
||||
message: OpenClawSessionHistoryMessage
|
||||
messageId?: string
|
||||
messageSeq: number
|
||||
}
|
||||
}
|
||||
| { type: 'error'; data: { message: string } }
|
||||
|
||||
export class OpenClawHttpClient {
|
||||
constructor(
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
@@ -39,6 +75,46 @@ export class OpenClawHttpChatClient {
|
||||
return createEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
const response = await this.fetchSessionHistory(sessionKey, input, {})
|
||||
return (await response.json()) as OpenClawSessionHistory
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput = {},
|
||||
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
|
||||
const response = await this.fetchSessionHistory(sessionKey, input, {
|
||||
Accept: 'text/event-stream',
|
||||
})
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw session history stream had no body')
|
||||
}
|
||||
return createHistoryEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/models`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
@@ -71,6 +147,50 @@ export class OpenClawHttpChatClient {
|
||||
detail || `OpenClaw chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
private async fetchSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput,
|
||||
extraHeaders: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new OpenClawSessionNotFoundError(sessionKey)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(
|
||||
detail ||
|
||||
`OpenClaw session history failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
function buildHistoryPath(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput,
|
||||
): string {
|
||||
const qs = new URLSearchParams()
|
||||
if (input.limit !== undefined) qs.set('limit', String(input.limit))
|
||||
if (input.cursor !== undefined) qs.set('cursor', input.cursor)
|
||||
const suffix = qs.toString()
|
||||
return `/sessions/${encodeURIComponent(sessionKey)}/history${
|
||||
suffix ? `?${suffix}` : ''
|
||||
}`
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
@@ -112,6 +232,7 @@ async function pumpChatEvents(
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel()
|
||||
done = true
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
@@ -128,6 +249,7 @@ async function pumpChatEvents(
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
done = true
|
||||
controller.close()
|
||||
}
|
||||
} finally {
|
||||
@@ -262,3 +384,104 @@ function parseChunk(data: string): Record<string, unknown> | null {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createHistoryEventStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): ReadableStream<OpenClawSessionHistoryEvent> {
|
||||
return new ReadableStream<OpenClawSessionHistoryEvent>({
|
||||
start(controller) {
|
||||
void pumpHistoryEvents(body, controller, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function pumpHistoryEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<OpenClawSessionHistoryEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
const parser = createParser({
|
||||
onEvent(message) {
|
||||
if (closed) return
|
||||
const event = toHistoryEvent(message)
|
||||
if (!event) return
|
||||
controller.enqueue(event)
|
||||
if (event.type === 'error') close()
|
||||
},
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
void reader.cancel().catch(() => {})
|
||||
close()
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel()
|
||||
close()
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
parser.feed(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
close()
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
close()
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function toHistoryEvent(
|
||||
message: EventSourceMessage,
|
||||
): OpenClawSessionHistoryEvent | null {
|
||||
if (!message.event) return null
|
||||
const payload = parseChunk(message.data)
|
||||
if (!payload) return null
|
||||
if (message.event === 'history') {
|
||||
return {
|
||||
type: 'history',
|
||||
data: payload as unknown as OpenClawSessionHistory,
|
||||
}
|
||||
}
|
||||
if (message.event === 'message') {
|
||||
return {
|
||||
type: 'message',
|
||||
data: payload as unknown as {
|
||||
sessionKey: string
|
||||
message: OpenClawSessionHistoryMessage
|
||||
messageId?: string
|
||||
messageSeq: number
|
||||
},
|
||||
}
|
||||
}
|
||||
if (message.event === 'error') {
|
||||
const errMessage =
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: 'OpenClaw session history stream error'
|
||||
return { type: 'error', data: { message: errMessage } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for raw JSONL line parsing (matches OpenClaw's internal format)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PiContentBlock {
|
||||
type: string
|
||||
text?: string
|
||||
id?: string
|
||||
name?: string
|
||||
arguments?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface PiMessage {
|
||||
role?: 'user' | 'assistant' | 'toolResult'
|
||||
content?: PiContentBlock[]
|
||||
stopReason?: string
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
input?: number
|
||||
output?: number
|
||||
cost?: {
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
model?: string
|
||||
provider?: string
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
interface PiLine {
|
||||
type: string
|
||||
id?: string
|
||||
timestamp?: string
|
||||
message?: PiMessage
|
||||
provider?: string
|
||||
modelId?: string
|
||||
thinkingLevel?: string
|
||||
summary?: string
|
||||
firstKeptEntryId?: string
|
||||
tokensBefore?: number
|
||||
}
|
||||
|
||||
interface SessionsJsonEntry {
|
||||
sessionId?: string
|
||||
updatedAt?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
type SessionsJson = Record<string, SessionsJsonEntry>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClawEventType =
|
||||
| 'user.message'
|
||||
| 'agent.message'
|
||||
| 'agent.thinking'
|
||||
| 'agent.tool_use'
|
||||
| 'agent.tool_result'
|
||||
| 'session.model_change'
|
||||
| 'session.thinking_level_change'
|
||||
| 'session.compaction'
|
||||
|
||||
export interface ClawEvent {
|
||||
eventId: string
|
||||
type: ClawEventType
|
||||
content: string
|
||||
createdAt: number
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
costUsd?: number
|
||||
model?: string
|
||||
toolName?: string
|
||||
toolCallId?: string
|
||||
toolArguments?: Record<string, unknown>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export interface JsonlSessionEntry {
|
||||
key: string
|
||||
sessionId: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface JsonlSessionStats {
|
||||
userTurns: number
|
||||
assistantMessages: number
|
||||
toolCalls: number
|
||||
totalCostUsd: number
|
||||
totalTokensIn: number
|
||||
totalTokensOut: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads OpenClaw's per-session JSONL files directly from the host filesystem.
|
||||
* OpenClaw is the sole writer — this reader never modifies the files.
|
||||
*
|
||||
* Path layout on the host (via Lima virtiofs mount):
|
||||
* <stateRoot>/agents/<agentId>/sessions/sessions.json
|
||||
* <stateRoot>/agents/<agentId>/sessions/<piSessionId>.jsonl
|
||||
*/
|
||||
export class OpenClawJsonlReader {
|
||||
constructor(private readonly stateRoot: string) {}
|
||||
|
||||
/** List all sessions for an agent by reading sessions.json. */
|
||||
listSessions(agentId: string): JsonlSessionEntry[] {
|
||||
const sessionsJson = this.readSessionsJson(agentId)
|
||||
if (!sessionsJson) return []
|
||||
|
||||
const entries: JsonlSessionEntry[] = []
|
||||
for (const [key, entry] of Object.entries(sessionsJson)) {
|
||||
if (typeof entry.sessionId === 'string') {
|
||||
entries.push({
|
||||
key,
|
||||
sessionId: entry.sessionId,
|
||||
updatedAt: typeof entry.updatedAt === 'number' ? entry.updatedAt : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
/** List all agent IDs by scanning the agents directory. */
|
||||
listAgents(): string[] {
|
||||
try {
|
||||
const entries = readdirSync(this.safePath('agents'), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and parse all events from a session's JSONL file. */
|
||||
listBySession(agentId: string, sessionKey: string): ClawEvent[] {
|
||||
const piSessionId = this.resolvePiSessionId(agentId, sessionKey)
|
||||
if (!piSessionId) return []
|
||||
|
||||
const filePath = this.jsonlPath(agentId, piSessionId)
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const events: ClawEvent[] = []
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
let parsed: PiLine
|
||||
try {
|
||||
parsed = JSON.parse(line) as PiLine
|
||||
} catch {
|
||||
// Skip malformed lines — a partial line at the tail is possible
|
||||
// if OpenClaw is mid-write.
|
||||
continue
|
||||
}
|
||||
for (const event of mapLineToEvents(parsed)) {
|
||||
events.push(event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Get the latest assistant message from a session. */
|
||||
latestAgentMessage(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): ClawEvent | undefined {
|
||||
const events = this.listBySession(agentId, sessionKey)
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (events[i]?.type === 'agent.message') return events[i]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Count user turns in a session. */
|
||||
countUserTurns(agentId: string, sessionKey: string): number {
|
||||
const events = this.listBySession(agentId, sessionKey)
|
||||
let n = 0
|
||||
for (const e of events) {
|
||||
if (e.type === 'user.message') n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Aggregate stats for a session. */
|
||||
getSessionStats(agentId: string, sessionKey: string): JsonlSessionStats {
|
||||
const events = this.listBySession(agentId, sessionKey)
|
||||
const stats: JsonlSessionStats = {
|
||||
userTurns: 0,
|
||||
assistantMessages: 0,
|
||||
toolCalls: 0,
|
||||
totalCostUsd: 0,
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
}
|
||||
for (const e of events) {
|
||||
if (e.type === 'user.message') stats.userTurns++
|
||||
if (e.type === 'agent.message') {
|
||||
stats.assistantMessages++
|
||||
if (e.costUsd) stats.totalCostUsd += e.costUsd
|
||||
if (e.tokensIn) stats.totalTokensIn += e.tokensIn
|
||||
if (e.tokensOut) stats.totalTokensOut += e.tokensOut
|
||||
}
|
||||
if (e.type === 'agent.tool_use') stats.toolCalls++
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure a resolved path stays within stateRoot to prevent path traversal
|
||||
* via crafted agentId or sessionId values containing ".." segments.
|
||||
*/
|
||||
private safePath(...segments: string[]): string {
|
||||
const resolved = resolve(this.stateRoot, ...segments)
|
||||
const root = resolve(this.stateRoot)
|
||||
if (!resolved.startsWith(`${root}/`) && resolved !== root) {
|
||||
throw new Error(`Path traversal blocked: ${segments.join('/')}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private readSessionsJson(agentId: string): SessionsJson | null {
|
||||
const filePath = this.safePath(
|
||||
'agents',
|
||||
agentId,
|
||||
'sessions',
|
||||
'sessions.json',
|
||||
)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw) as SessionsJson
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePiSessionId(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): string | undefined {
|
||||
const sessionsJson = this.readSessionsJson(agentId)
|
||||
if (!sessionsJson) return undefined
|
||||
|
||||
// Try exact key match first
|
||||
const entry = sessionsJson[sessionKey]
|
||||
if (entry && typeof entry.sessionId === 'string') {
|
||||
return entry.sessionId
|
||||
}
|
||||
|
||||
// Try matching by scanning all keys (handles key format variations)
|
||||
for (const [key, value] of Object.entries(sessionsJson)) {
|
||||
if (key === sessionKey || key.endsWith(`:${sessionKey}`)) {
|
||||
if (typeof value.sessionId === 'string') return value.sessionId
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private jsonlPath(agentId: string, piSessionId: string): string {
|
||||
return this.safePath('agents', agentId, 'sessions', `${piSessionId}.jsonl`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSONL line → ClawEvent mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapLineToEvents(line: PiLine): ClawEvent[] {
|
||||
const eventId = line.id ?? ''
|
||||
const createdAt = line.timestamp ? Date.parse(line.timestamp) : Date.now()
|
||||
|
||||
if (line.type === 'model_change') {
|
||||
const model = combineModel(line.provider, line.modelId)
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'session.model_change',
|
||||
content: model,
|
||||
createdAt,
|
||||
model,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (line.type === 'thinking_level_change') {
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'session.thinking_level_change',
|
||||
content: line.thinkingLevel ?? 'unknown',
|
||||
createdAt,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (line.type === 'compaction') {
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'session.compaction',
|
||||
content: line.summary ?? '(compacted)',
|
||||
createdAt,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (line.type !== 'message' || !line.message) return []
|
||||
|
||||
return mapMessageToEvents(line.message, eventId, createdAt)
|
||||
}
|
||||
|
||||
function mapMessageToEvents(
|
||||
msg: PiMessage,
|
||||
eventId: string,
|
||||
createdAt: number,
|
||||
): ClawEvent[] {
|
||||
if (msg.role === 'user') {
|
||||
const text = extractText(msg.content)
|
||||
if (!text) return []
|
||||
return [{ eventId, type: 'user.message', content: text, createdAt }]
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
return mapAssistantMessage(msg, eventId, createdAt)
|
||||
}
|
||||
|
||||
if (msg.role === 'toolResult') {
|
||||
const text = extractText(msg.content)
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'agent.tool_result',
|
||||
content: text || '(no output)',
|
||||
createdAt,
|
||||
toolName: msg.toolName,
|
||||
toolCallId: msg.toolCallId,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function mapAssistantMessage(
|
||||
msg: PiMessage,
|
||||
eventId: string,
|
||||
createdAt: number,
|
||||
): ClawEvent[] {
|
||||
const events: ClawEvent[] = []
|
||||
const text = extractText(msg.content)
|
||||
|
||||
if (msg.content) {
|
||||
let thinkingIdx = 0
|
||||
let toolIdx = 0
|
||||
for (const block of msg.content) {
|
||||
if (
|
||||
block.type === 'thinking' &&
|
||||
typeof block.text === 'string' &&
|
||||
block.text.length > 0
|
||||
) {
|
||||
events.push({
|
||||
eventId: `${eventId}:thinking:${thinkingIdx}`,
|
||||
type: 'agent.thinking',
|
||||
content: block.text,
|
||||
createdAt,
|
||||
})
|
||||
thinkingIdx++
|
||||
}
|
||||
if (block.type === 'toolCall' && block.name) {
|
||||
events.push({
|
||||
eventId: `${eventId}:tool:${block.id ?? toolIdx}`,
|
||||
type: 'agent.tool_use',
|
||||
content: block.name,
|
||||
createdAt,
|
||||
toolName: block.name,
|
||||
toolCallId: block.id,
|
||||
toolArguments: block.arguments,
|
||||
})
|
||||
toolIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
events.push({
|
||||
eventId,
|
||||
type: 'agent.message',
|
||||
content: text,
|
||||
createdAt,
|
||||
tokensIn: msg.usage?.input,
|
||||
tokensOut: msg.usage?.output,
|
||||
costUsd: msg.usage?.cost?.total,
|
||||
model: combineModel(msg.provider, msg.model),
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function extractText(blocks: PiContentBlock[] | undefined): string {
|
||||
if (!blocks || blocks.length === 0) return ''
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text)
|
||||
}
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function combineModel(
|
||||
provider: string | undefined,
|
||||
model: string | undefined,
|
||||
): string | undefined {
|
||||
if (!model) return undefined
|
||||
return provider ? `${provider}/${model}` : model
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Main orchestrator for OpenClaw integration.
|
||||
* Container lifecycle via Podman, agent CRUD via in-container CLI,
|
||||
* Container lifecycle via the VM runtime, agent CRUD via in-container CLI,
|
||||
* chat via HTTP /v1/chat/completions proxy.
|
||||
*/
|
||||
|
||||
@@ -18,10 +18,14 @@ import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { MonitoringChatTurn } from '../../../monitoring/types'
|
||||
import {
|
||||
import type {
|
||||
ContainerRuntime,
|
||||
type GatewayContainerSpec,
|
||||
GatewayContainerSpec,
|
||||
} from './container-runtime'
|
||||
import {
|
||||
buildContainerRuntime,
|
||||
type VmCacheRuntimeConfig,
|
||||
} from './container-runtime-factory'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -33,6 +37,15 @@ import {
|
||||
OpenClawCliClient,
|
||||
type OpenClawConfigBatchEntry,
|
||||
} from './openclaw-cli-client'
|
||||
import {
|
||||
buildOpenClawCliProviderModelRef,
|
||||
getOpenClawCliProvider,
|
||||
OPENCLAW_CLI_PROVIDERS,
|
||||
} from './openclaw-cli-providers/registry'
|
||||
import type {
|
||||
OpenClawCliProvider,
|
||||
OpenClawCliProviderAuthStatus,
|
||||
} from './openclaw-cli-providers/types'
|
||||
import {
|
||||
getHostWorkspaceDir,
|
||||
getOpenClawStateConfigPath,
|
||||
@@ -40,18 +53,23 @@ import {
|
||||
getOpenClawStateEnvPath,
|
||||
mergeEnvContent,
|
||||
} from './openclaw-env'
|
||||
import { OpenClawHttpChatClient } from './openclaw-http-chat-client'
|
||||
import {
|
||||
OpenClawHttpClient,
|
||||
type OpenClawSessionHistory,
|
||||
type OpenClawSessionHistoryEvent,
|
||||
} from './openclaw-http-client'
|
||||
import { type ClawEvent, OpenClawJsonlReader } from './openclaw-jsonl-reader'
|
||||
import {
|
||||
type ResolvedOpenClawProviderConfig,
|
||||
resolveSupportedOpenClawProvider,
|
||||
} from './openclaw-provider-map'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
|
||||
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
|
||||
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'
|
||||
@@ -108,18 +126,215 @@ export interface OpenClawProviderUpdateResult {
|
||||
export interface OpenClawServiceConfig {
|
||||
browserosServerPort?: number
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}
|
||||
|
||||
export interface OpenClawPodmanOverridesResponse {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
export type OpenClawSessionSource =
|
||||
| 'user-chat'
|
||||
| 'cron'
|
||||
| 'hook'
|
||||
| 'channel'
|
||||
| 'other'
|
||||
|
||||
export interface BrowserOSOpenClawSession {
|
||||
key: string
|
||||
updatedAt: number
|
||||
sessionId: string
|
||||
agentId: string
|
||||
kind: string
|
||||
source: OpenClawSessionSource
|
||||
status?: string
|
||||
totalTokens?: number
|
||||
model?: string
|
||||
modelProvider?: string
|
||||
}
|
||||
|
||||
export interface BrowserOSOpenClawAgentSessionResponse {
|
||||
agentId: string
|
||||
exists: boolean
|
||||
sessionKey: string | null
|
||||
session: BrowserOSOpenClawSession | null
|
||||
}
|
||||
|
||||
export interface BrowserOSChatHistoryItem {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
timestamp?: number
|
||||
messageSeq: number
|
||||
sessionKey: string
|
||||
source: OpenClawSessionSource
|
||||
}
|
||||
|
||||
export interface BrowserOSOpenClawHistoryPageResponse {
|
||||
agentId: string
|
||||
sessionKey: string | null
|
||||
session: BrowserOSOpenClawSession | null
|
||||
items: BrowserOSChatHistoryItem[]
|
||||
page: {
|
||||
cursor?: string
|
||||
hasMore: boolean
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
interface HistoryPageInput {
|
||||
sessionKey?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function normalizeBrowserOSChatSessionKey(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): string {
|
||||
const trimmed = sessionKey.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
let normalized = trimmed
|
||||
const agentSpecificPrefix = getOpenClawBrowserOSSessionPrefix(agentId)
|
||||
|
||||
while (normalized.startsWith(agentSpecificPrefix)) {
|
||||
normalized = normalized.slice(agentSpecificPrefix.length)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const match = normalized.match(OPENCLAW_BROWSEROS_USER_SESSION_PATTERN)
|
||||
if (!match?.[1]) break
|
||||
normalized = match[1]
|
||||
}
|
||||
|
||||
return normalized.trim() || trimmed
|
||||
}
|
||||
|
||||
function getOpenClawBrowserOSSessionPrefix(agentId: string): string {
|
||||
return `agent:${agentId}:openai-user:browseros:${agentId}:`
|
||||
}
|
||||
|
||||
function toOpenClawBrowserOSSessionKey(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): string {
|
||||
return `${getOpenClawBrowserOSSessionPrefix(agentId)}${normalizeBrowserOSChatSessionKey(
|
||||
agentId,
|
||||
sessionKey,
|
||||
)}`
|
||||
}
|
||||
|
||||
function normalizeHistoryLimit(limit?: number): number {
|
||||
if (limit === undefined || !Number.isFinite(limit)) return 50
|
||||
return Math.max(1, Math.min(100, Math.trunc(limit)))
|
||||
}
|
||||
|
||||
function classifySessionSource(key: string): OpenClawSessionSource {
|
||||
if (key.includes(':cron:')) return 'cron'
|
||||
if (key.includes(':hook:')) return 'hook'
|
||||
if (key.includes('openai-user:browseros')) return 'user-chat'
|
||||
if (key.includes('qa-channel')) return 'channel'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert JSONL events to BrowserOS chat history items, applying the same
|
||||
* filtering rules as the old HTTP-based pipeline (filterHttpSessionHistoryMessages).
|
||||
*/
|
||||
function jsonlEventsToHistoryItems(
|
||||
events: ClawEvent[],
|
||||
sessionKey: string,
|
||||
source: OpenClawSessionSource,
|
||||
): BrowserOSChatHistoryItem[] {
|
||||
const items: BrowserOSChatHistoryItem[] = []
|
||||
let seq = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type !== 'user.message' && event.type !== 'agent.message') {
|
||||
continue
|
||||
}
|
||||
|
||||
let text = event.content.trim()
|
||||
if (!text) continue
|
||||
|
||||
// Filter assistant heartbeats
|
||||
if (event.type === 'agent.message' && text.startsWith('HEARTBEAT')) continue
|
||||
|
||||
// Filter internal reminders
|
||||
if (
|
||||
event.type === 'user.message' &&
|
||||
text.includes('Handle this reminder internally')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract actual user text from context-replay wrappers
|
||||
if (
|
||||
event.type === 'user.message' &&
|
||||
text.startsWith('[Chat messages since your last reply')
|
||||
) {
|
||||
const marker = '[Current message - respond to this]'
|
||||
const index = text.indexOf(marker)
|
||||
if (index >= 0) {
|
||||
text = text
|
||||
.slice(index + marker.length)
|
||||
.trim()
|
||||
.replace(/^User:\s*/i, '')
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (!text) continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: `${sessionKey}:${seq}`,
|
||||
role: event.type === 'user.message' ? 'user' : 'assistant',
|
||||
text,
|
||||
timestamp: event.createdAt,
|
||||
messageSeq: seq,
|
||||
sessionKey,
|
||||
source,
|
||||
})
|
||||
seq++
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function encodeHistoryCursor(input: {
|
||||
sessionKey: string
|
||||
end: number
|
||||
}): string {
|
||||
return Buffer.from(JSON.stringify(input), 'utf-8').toString('base64url')
|
||||
}
|
||||
|
||||
function decodeHistoryCursor(
|
||||
cursor?: string,
|
||||
): { sessionKey: string; end: number } | null {
|
||||
if (!cursor) return null
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf-8'),
|
||||
) as {
|
||||
sessionKey?: unknown
|
||||
end?: unknown
|
||||
}
|
||||
if (typeof parsed.sessionKey !== 'string') return null
|
||||
if (typeof parsed.end !== 'number' || !Number.isFinite(parsed.end)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
sessionKey: parsed.sessionKey,
|
||||
end: Math.max(0, Math.trunc(parsed.end)),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawService {
|
||||
private runtime: ContainerRuntime
|
||||
private cliClient: OpenClawCliClient
|
||||
private bootstrapCliClient: OpenClawCliClient
|
||||
private chatClient: OpenClawHttpChatClient
|
||||
private httpClient: OpenClawHttpClient
|
||||
private openclawDir: string
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
@@ -127,33 +342,75 @@ export class OpenClawService {
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
private browserosDir: string | undefined
|
||||
private vmCache: VmCacheRuntimeConfig | 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 _jsonlReader: OpenClawJsonlReader | null = null
|
||||
private get jsonlReader(): OpenClawJsonlReader {
|
||||
if (!this._jsonlReader) {
|
||||
this._jsonlReader = new OpenClawJsonlReader(
|
||||
getOpenClawStateDir(this.openclawDir),
|
||||
)
|
||||
}
|
||||
return this._jsonlReader
|
||||
}
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.runtime = buildContainerRuntime({
|
||||
resourcesDir: config.resourcesDir,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: config.browserosDir,
|
||||
vmCache: config.vmCache,
|
||||
})
|
||||
this.token = crypto.randomUUID()
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
this.browserosDir = config.browserosDir
|
||||
this.vmCache = config.vmCache
|
||||
}
|
||||
|
||||
configure(config: OpenClawServiceConfig): void {
|
||||
if (config.browserosServerPort !== undefined) {
|
||||
this.browserosServerPort = config.browserosServerPort
|
||||
}
|
||||
if (config.resourcesDir !== undefined) {
|
||||
|
||||
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 (
|
||||
config.vmCache !== undefined &&
|
||||
!sameVmCacheRuntimeConfig(config.vmCache, this.vmCache)
|
||||
) {
|
||||
this.vmCache = config.vmCache
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (runtimeChanged) {
|
||||
this.rebuildRuntimeClients()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +423,7 @@ export class OpenClawService {
|
||||
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
|
||||
return this.withLifecycleLock('setup', async () => {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
const provider = this.resolveProviderForAgent(input)
|
||||
logger.info('Starting OpenClaw setup', {
|
||||
hostPort: this.hostPort,
|
||||
browserosServerPort: this.browserosServerPort,
|
||||
@@ -177,14 +434,6 @@ export class OpenClawService {
|
||||
hasApiKey: !!input.apiKey,
|
||||
})
|
||||
|
||||
logProgress('Checking container runtime...')
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
'Podman is not available. Install Podman to use OpenClaw agents.',
|
||||
)
|
||||
}
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
logProgress('Container runtime ready')
|
||||
|
||||
@@ -198,10 +447,7 @@ export class OpenClawService {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
logProgress('Pulling OpenClaw image...')
|
||||
await this.runtime.pullImage(this.getGatewayImage(), logProgress)
|
||||
logProgress('Image ready')
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
@@ -225,8 +471,7 @@ export class OpenClawService {
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.refreshGatewayAuthToken()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
@@ -250,6 +495,8 @@ export class OpenClawService {
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
|
||||
await this.ensureAllCliProvidersInstalled(logProgress)
|
||||
|
||||
const existingAgents = await this.listAgents()
|
||||
logger.info('Fetched existing OpenClaw agents after setup', {
|
||||
count: existingAgents.length,
|
||||
@@ -285,8 +532,7 @@ export class OpenClawService {
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
@@ -330,6 +576,7 @@ export class OpenClawService {
|
||||
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 })
|
||||
})
|
||||
@@ -353,10 +600,10 @@ export class OpenClawService {
|
||||
})
|
||||
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
@@ -378,6 +625,7 @@ export class OpenClawService {
|
||||
|
||||
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 })
|
||||
@@ -401,8 +649,7 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
logProgress('Reloading gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
@@ -418,28 +665,13 @@ export class OpenClawService {
|
||||
} catch {
|
||||
// Best effort during shutdown
|
||||
}
|
||||
await this.runtime.stopMachineIfSafe()
|
||||
await this.runtime.stopVm()
|
||||
logger.info('OpenClaw shutdown complete')
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
async getStatus(): Promise<OpenClawStatusResponse> {
|
||||
const podmanAvailable = await this.runtime.isPodmanAvailable()
|
||||
if (!podmanAvailable) {
|
||||
return {
|
||||
status: 'uninitialized',
|
||||
podmanAvailable: false,
|
||||
machineReady: false,
|
||||
port: null,
|
||||
agentCount: 0,
|
||||
error: null,
|
||||
controlPlaneStatus: 'disconnected',
|
||||
lastGatewayError: null,
|
||||
lastRecoveryReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
const isSetUp = existsSync(this.getStateConfigPath())
|
||||
if (!isSetUp) {
|
||||
const machineStatus = await this.runtime.getMachineStatus()
|
||||
@@ -511,7 +743,7 @@ export class OpenClawService {
|
||||
})
|
||||
await this.assertGatewayReady()
|
||||
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
const provider = this.resolveProviderForAgent(input)
|
||||
const configChanged = await this.mergeProviderConfigIfChanged(provider)
|
||||
const keysChanged = await this.writeStateEnv(provider.envValues)
|
||||
|
||||
@@ -573,6 +805,97 @@ export class OpenClawService {
|
||||
return this.runControlPlaneCall(() => this.cliClient.listAgents())
|
||||
}
|
||||
|
||||
listSessions(agentId?: string): BrowserOSOpenClawSession[] {
|
||||
logger.debug('Listing OpenClaw sessions', { agentId })
|
||||
|
||||
const agentIds = agentId ? [agentId] : this.jsonlReader.listAgents()
|
||||
|
||||
const sessions: BrowserOSOpenClawSession[] = []
|
||||
for (const id of agentIds) {
|
||||
for (const entry of this.jsonlReader.listSessions(id)) {
|
||||
sessions.push({
|
||||
key: entry.key,
|
||||
updatedAt: entry.updatedAt,
|
||||
sessionId: entry.sessionId,
|
||||
agentId: id,
|
||||
kind: 'chat',
|
||||
source: classifySessionSource(entry.key),
|
||||
})
|
||||
}
|
||||
}
|
||||
return sessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
resolveAgentSession(agentId: string): BrowserOSOpenClawAgentSessionResponse {
|
||||
const sessions = this.listSessions(agentId)
|
||||
const session =
|
||||
sessions.find((entry) => entry.source === 'user-chat') ??
|
||||
sessions.find((entry) => entry.kind.toLowerCase().includes('chat')) ??
|
||||
sessions[0] ??
|
||||
null
|
||||
|
||||
if (session) {
|
||||
return this.resolveSpecificAgentSession(agentId, session.key)
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
exists: false,
|
||||
sessionKey: null,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
getAgentHistoryPage(
|
||||
agentId: string,
|
||||
input: HistoryPageInput = {},
|
||||
): BrowserOSOpenClawHistoryPageResponse {
|
||||
const limit = normalizeHistoryLimit(input.limit)
|
||||
const cursor = decodeHistoryCursor(input.cursor)
|
||||
const resolved = cursor?.sessionKey
|
||||
? this.resolveSpecificAgentSession(agentId, cursor.sessionKey)
|
||||
: input.sessionKey
|
||||
? this.resolveSpecificAgentSession(agentId, input.sessionKey)
|
||||
: this.resolveAgentSession(agentId)
|
||||
|
||||
const session = resolved.session
|
||||
if (!session) {
|
||||
return {
|
||||
agentId,
|
||||
sessionKey: null,
|
||||
session: null,
|
||||
items: [],
|
||||
page: { hasMore: false, limit },
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey =
|
||||
resolved.sessionKey ??
|
||||
normalizeBrowserOSChatSessionKey(agentId, session.key)
|
||||
|
||||
// Read JSONL directly from the host filesystem via Lima virtiofs mount
|
||||
const events = this.jsonlReader.listBySession(agentId, session.key)
|
||||
const items = jsonlEventsToHistoryItems(events, sessionKey, session.source)
|
||||
|
||||
const end = Math.min(cursor?.end ?? items.length, items.length)
|
||||
const start = Math.max(0, end - limit)
|
||||
const pageItems = items.slice(start, end)
|
||||
const nextCursor =
|
||||
start > 0 ? encodeHistoryCursor({ sessionKey, end: start }) : undefined
|
||||
|
||||
return {
|
||||
agentId,
|
||||
sessionKey,
|
||||
session,
|
||||
items: pageItems,
|
||||
page: {
|
||||
cursor: nextCursor,
|
||||
hasMore: start > 0,
|
||||
limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chat Stream (HTTP) ───────────────────────────────────────────────
|
||||
|
||||
async chatStream(
|
||||
@@ -582,62 +905,88 @@ export class OpenClawService {
|
||||
history: MonitoringChatTurn[] = [],
|
||||
): Promise<ReadableStream<OpenClawStreamEvent>> {
|
||||
await this.assertGatewayReady()
|
||||
logger.info('Starting OpenClaw chat stream', {
|
||||
const normalizedSessionKey = normalizeBrowserOSChatSessionKey(
|
||||
agentId,
|
||||
sessionKey,
|
||||
)
|
||||
logger.info('Starting OpenClaw chat stream', {
|
||||
agentId,
|
||||
sessionKey: normalizedSessionKey,
|
||||
messageLength: message.length,
|
||||
historyLength: history.length,
|
||||
})
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.chatClient.streamChat({
|
||||
this.httpClient.streamChat({
|
||||
agentId,
|
||||
sessionKey,
|
||||
sessionKey: normalizedSessionKey,
|
||||
message,
|
||||
history,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Podman Overrides ─────────────────────────────────────────────────
|
||||
private resolveSpecificAgentSession(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): BrowserOSOpenClawAgentSessionResponse {
|
||||
const normalizedSessionKey = normalizeBrowserOSChatSessionKey(
|
||||
agentId,
|
||||
sessionKey,
|
||||
)
|
||||
const canonicalSessionKey = toOpenClawBrowserOSSessionKey(
|
||||
agentId,
|
||||
normalizedSessionKey,
|
||||
)
|
||||
const sessions = this.listSessions(agentId)
|
||||
const session =
|
||||
sessions.find((entry) => entry.key === canonicalSessionKey) ??
|
||||
sessions.find((entry) => entry.key === sessionKey) ??
|
||||
sessions.find(
|
||||
(entry) =>
|
||||
normalizeBrowserOSChatSessionKey(agentId, entry.key) ===
|
||||
normalizedSessionKey,
|
||||
)
|
||||
|
||||
async applyPodmanOverrides(input: {
|
||||
podmanPath: string | null
|
||||
}): Promise<OpenClawPodmanOverridesResponse> {
|
||||
await savePodmanOverrides(this.openclawDir, {
|
||||
podmanPath: input.podmanPath,
|
||||
})
|
||||
|
||||
// Intentionally mutates the module-level PodmanRuntime singleton so every
|
||||
// consumer (including future service instances) sees the new path.
|
||||
configurePodmanRuntime({
|
||||
resourcesDir: this.resourcesDir ?? undefined,
|
||||
podmanPath: input.podmanPath ?? undefined,
|
||||
})
|
||||
|
||||
this.rebuildRuntimeClients()
|
||||
const effectivePodmanPath = getPodmanRuntime().getPodmanPath()
|
||||
|
||||
logger.info('Applied Podman overrides', {
|
||||
podmanPath: input.podmanPath,
|
||||
effectivePodmanPath,
|
||||
})
|
||||
if (!session) {
|
||||
return {
|
||||
agentId,
|
||||
exists: false,
|
||||
sessionKey: normalizedSessionKey,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
podmanPath: input.podmanPath,
|
||||
effectivePodmanPath,
|
||||
agentId,
|
||||
exists: true,
|
||||
sessionKey: normalizedSessionKey,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
async getPodmanOverrides(): Promise<OpenClawPodmanOverridesResponse> {
|
||||
const { podmanPath } = await loadPodmanOverrides(this.openclawDir)
|
||||
return {
|
||||
podmanPath,
|
||||
effectivePodmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
}
|
||||
// ── Session History (HTTP) ───────────────────────────────────────────
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
await this.assertGatewayReady()
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.getSessionHistory(sessionKey, input),
|
||||
)
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
sessionKey: string,
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
|
||||
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
|
||||
await this.assertGatewayReady()
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.streamSessionHistory(sessionKey, input),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Provider Keys ────────────────────────────────────────────────────
|
||||
|
||||
async updateProviderKeys(input: {
|
||||
providerType: string
|
||||
providerName?: string
|
||||
@@ -645,7 +994,7 @@ export class OpenClawService {
|
||||
apiKey: string
|
||||
modelId?: string
|
||||
}): Promise<OpenClawProviderUpdateResult> {
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
const provider = this.resolveProviderForAgent(input)
|
||||
const configChanged = await this.mergeProviderConfigIfChanged(provider)
|
||||
const envChanged = await this.writeStateEnv(provider.envValues)
|
||||
const restarted = configChanged || envChanged
|
||||
@@ -667,6 +1016,17 @@ export class OpenClawService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI-backed Providers ─────────────────────────────────────────────
|
||||
|
||||
async getCliProviderAuthStatus(
|
||||
provider: OpenClawCliProvider,
|
||||
): Promise<OpenClawCliProviderAuthStatus> {
|
||||
const { stdout, exitCode } = await this.runtime.runInContainer(
|
||||
provider.authStatusCommand,
|
||||
)
|
||||
return provider.parseAuthStatus(stdout, exitCode)
|
||||
}
|
||||
|
||||
// ── Logs ─────────────────────────────────────────────────────────────
|
||||
|
||||
async getLogs(tail = 100): Promise<string[]> {
|
||||
@@ -681,8 +1041,6 @@ export class OpenClawService {
|
||||
const isSetUp = existsSync(this.getStateConfigPath())
|
||||
if (!isSetUp) return
|
||||
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) return
|
||||
logger.info('Attempting OpenClaw auto-start', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
@@ -690,8 +1048,7 @@ export class OpenClawService {
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
@@ -699,7 +1056,7 @@ export class OpenClawService {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
|
||||
if (!(await this.runtime.isReady(this.hostPort))) {
|
||||
if (!(await this.isGatewayAvailable(this.hostPort))) {
|
||||
await this.ensureGatewayPortAllocated()
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
const ready = await this.runtime.waitForReady(
|
||||
@@ -713,6 +1070,7 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
await this.ensureAllCliProvidersInstalled()
|
||||
logger.info('OpenClaw gateway auto-started')
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
@@ -724,6 +1082,77 @@ export class OpenClawService {
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
// CLI-provider short-circuit: skip env writes and custom-provider merges,
|
||||
// just build the `<id>/<model>` ref that OpenClaw's own plugin routes to.
|
||||
private resolveProviderForAgent(
|
||||
input: SetupInput,
|
||||
): ResolvedOpenClawProviderConfig {
|
||||
const cliProvider = input.providerType
|
||||
? getOpenClawCliProvider(input.providerType)
|
||||
: undefined
|
||||
if (cliProvider) {
|
||||
return {
|
||||
envValues: {},
|
||||
model: input.modelId
|
||||
? buildOpenClawCliProviderModelRef(cliProvider.id, input.modelId)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
return resolveSupportedOpenClawProvider(input)
|
||||
}
|
||||
|
||||
private async ensureAllCliProvidersInstalled(
|
||||
onLog?: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
// Test mocks may swap `this.runtime` for a partial stub without
|
||||
// execInContainer. Skip silently — production ContainerRuntime always
|
||||
// provides it.
|
||||
if (typeof this.runtime.execInContainer !== 'function') return
|
||||
for (const provider of OPENCLAW_CLI_PROVIDERS) {
|
||||
await this.ensureCliProviderInstalled(provider, onLog)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCliProviderInstalled(
|
||||
provider: OpenClawCliProvider,
|
||||
onLog?: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
// argv probe — no shell, no interpolation: `which` returns 0 if the
|
||||
// binary is on PATH in the container, non-zero otherwise.
|
||||
const probe = await this.runtime.execInContainer(['which', provider.binary])
|
||||
if (probe === 0) {
|
||||
logger.info('CLI-backed provider already present', {
|
||||
providerId: provider.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// argv install — registry values flow straight through nerdctl exec,
|
||||
// never through a shell. Version is pinned in the provider registry.
|
||||
const lines: string[] = []
|
||||
const exitCode = await this.runtime.execInContainer(
|
||||
[
|
||||
'npm',
|
||||
'install',
|
||||
'-g',
|
||||
`${provider.npmPackage}@${provider.npmPackageVersion}`,
|
||||
],
|
||||
(line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
)
|
||||
if (exitCode !== 0) {
|
||||
logger.warn('CLI-backed provider install failed', {
|
||||
providerId: provider.id,
|
||||
exitCode,
|
||||
tail: lines.slice(-5),
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.info('CLI-backed provider installed', { providerId: provider.id })
|
||||
}
|
||||
|
||||
private buildBootstrapCliClient(): OpenClawCliClient {
|
||||
return new OpenClawCliClient({
|
||||
execInContainer: (command, onLog) =>
|
||||
@@ -737,15 +1166,21 @@ export class OpenClawService {
|
||||
|
||||
private rebuildRuntimeClients(): void {
|
||||
this.stopGatewayLogTail()
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.runtime = buildContainerRuntime({
|
||||
resourcesDir: this.resourcesDir ?? undefined,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: this.browserosDir,
|
||||
vmCache: this.vmCache,
|
||||
})
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this._jsonlReader = null
|
||||
}
|
||||
|
||||
private setPort(hostPort: number): void {
|
||||
if (hostPort === this.hostPort) return
|
||||
this.hostPort = hostPort
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
@@ -770,9 +1205,34 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
|
||||
if (await this.runtime.isReady(hostPort)) {
|
||||
return true
|
||||
if (!(await this.isGatewayPortReady(hostPort))) return false
|
||||
|
||||
if (!this.tokenLoaded) {
|
||||
logger.debug(
|
||||
'OpenClaw gateway port is ready before auth token is loaded',
|
||||
{
|
||||
hostPort,
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const client =
|
||||
hostPort === this.hostPort
|
||||
? this.httpClient
|
||||
: new OpenClawHttpClient(hostPort, async () => this.token)
|
||||
const authenticated = await client.isAuthenticated()
|
||||
if (!authenticated) {
|
||||
logger.warn('OpenClaw gateway port rejected current auth token', {
|
||||
hostPort,
|
||||
})
|
||||
}
|
||||
return authenticated
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -1158,6 +1618,15 @@ export class OpenClawService {
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async refreshGatewayAuthToken(): Promise<void> {
|
||||
this.tokenLoaded = false
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async loadTokenFromConfig(): Promise<void> {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
@@ -1224,7 +1693,26 @@ export function configureOpenClawService(
|
||||
return service
|
||||
}
|
||||
|
||||
export function configureVmRuntime(config: {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}): OpenClawService {
|
||||
return configureOpenClawService(config)
|
||||
}
|
||||
|
||||
export function getOpenClawService(): OpenClawService {
|
||||
if (!service) service = new OpenClawService()
|
||||
return service
|
||||
}
|
||||
|
||||
function sameVmCacheRuntimeConfig(
|
||||
left: VmCacheRuntimeConfig | undefined,
|
||||
right: VmCacheRuntimeConfig | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left?.manifestUrl === right?.manifestUrl &&
|
||||
left?.ensureAvailable === right?.ensureAvailable &&
|
||||
left?.ensureSynced === right?.ensureSynced
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Persistence for user-supplied Podman runtime overrides.
|
||||
* Temporary escape hatch so users can point BrowserOS at their own Podman
|
||||
* (e.g. `brew install podman`) when the bundled runtime doesn't resolve helpers.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
}
|
||||
|
||||
const OVERRIDES_FILE_NAME = 'podman-overrides.json'
|
||||
|
||||
export function getPodmanOverridesPath(openclawDir: string): string {
|
||||
return join(openclawDir, OVERRIDES_FILE_NAME)
|
||||
}
|
||||
|
||||
export async function loadPodmanOverrides(
|
||||
openclawDir: string,
|
||||
): Promise<PodmanOverrides> {
|
||||
const overridesPath = getPodmanOverridesPath(openclawDir)
|
||||
if (!existsSync(overridesPath)) return { podmanPath: null }
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(overridesPath, 'utf-8'),
|
||||
) as Partial<PodmanOverrides>
|
||||
return {
|
||||
podmanPath:
|
||||
typeof parsed.podmanPath === 'string' && parsed.podmanPath.length > 0
|
||||
? parsed.podmanPath
|
||||
: null,
|
||||
}
|
||||
} catch {
|
||||
return { podmanPath: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePodmanOverrides(
|
||||
openclawDir: string,
|
||||
overrides: PodmanOverrides,
|
||||
): Promise<void> {
|
||||
await mkdir(openclawDir, { recursive: true })
|
||||
await writeFile(
|
||||
getPodmanOverridesPath(openclawDir),
|
||||
`${JSON.stringify(overrides, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstraction over the Podman CLI for container lifecycle management.
|
||||
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
|
||||
* On Linux, machine operations are no-ops since Podman runs natively.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const isLinux = process.platform === 'linux'
|
||||
const PODMAN_BUNDLE_PATH = ['bin', 'third_party', 'podman'] as const
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
function getPodmanBinaryName(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'podman.exe' : 'podman'
|
||||
}
|
||||
|
||||
export function resolveBundledPodmanPath(
|
||||
resourcesDir?: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string | null {
|
||||
if (!resourcesDir) return null
|
||||
|
||||
const bundledPath = join(
|
||||
resourcesDir,
|
||||
...PODMAN_BUNDLE_PATH,
|
||||
getPodmanBinaryName(platform),
|
||||
)
|
||||
|
||||
return existsSync(bundledPath) ? bundledPath : null
|
||||
}
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
}
|
||||
|
||||
getPodmanPath(): string {
|
||||
return this.podmanPath
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn([this.podmanPath, '--version'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
return (await proc.exited) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
if (isLinux) return { initialized: true, running: true }
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'machine', 'list', '--format', 'json'],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
)
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
const machines = JSON.parse(output) as Array<{
|
||||
Running?: boolean
|
||||
LastUp?: string
|
||||
}>
|
||||
|
||||
if (!machines.length) return { initialized: false, running: false }
|
||||
|
||||
const machine = machines[0]
|
||||
const running =
|
||||
machine.Running === true || machine.LastUp === 'Currently running'
|
||||
|
||||
return { initialized: true, running }
|
||||
} catch {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
}
|
||||
|
||||
async initMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.podmanPath,
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'8',
|
||||
'--memory',
|
||||
'8096',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine init failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async stopMachine(): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
const status = await this.getMachineStatus()
|
||||
|
||||
if (!status.initialized) {
|
||||
onLog?.('Initializing Podman machine...')
|
||||
await this.initMachine(onLog)
|
||||
}
|
||||
|
||||
if (!status.running) {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
onOutput?: (line: string) => void
|
||||
},
|
||||
): Promise<number> {
|
||||
const useStreaming = !!options?.onOutput
|
||||
const proc = Bun.spawn([this.podmanPath, ...args], {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
||||
stdout: useStreaming ? 'pipe' : 'ignore',
|
||||
stderr: useStreaming ? 'pipe' : 'ignore',
|
||||
})
|
||||
|
||||
if (options?.onOutput) {
|
||||
await Promise.all([
|
||||
this.drainStream(proc.stdout ?? null, options.onOutput),
|
||||
this.drainStream(proc.stderr ?? null, options.onOutput),
|
||||
])
|
||||
}
|
||||
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow container logs. Returns a stop function that terminates the
|
||||
* underlying `podman logs -f` process. Each output line is passed to
|
||||
* onLine as-is.
|
||||
*/
|
||||
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'logs', '-f', '--tail', '0', containerName],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
void this.drainStream(proc.stdout ?? null, onLine)
|
||||
void this.drainStream(proc.stderr ?? null, onLine)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists running container names. Used to check whether non-BrowserOS
|
||||
* containers are running before stopping the Podman machine.
|
||||
*/
|
||||
async listRunningContainers(): Promise<string[]> {
|
||||
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((name) => name.trim())
|
||||
}
|
||||
|
||||
private async drainStderr(
|
||||
proc: {
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
},
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
if (!onLog || !proc.stderr) return
|
||||
await this.drainStream(proc.stderr, onLog)
|
||||
}
|
||||
|
||||
private async drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PodmanRuntime | null = null
|
||||
|
||||
export function configurePodmanRuntime(config: {
|
||||
resourcesDir?: string
|
||||
podmanPath?: string
|
||||
}): PodmanRuntime {
|
||||
const podmanPath =
|
||||
config.podmanPath ??
|
||||
resolveBundledPodmanPath(config.resourcesDir) ??
|
||||
'podman'
|
||||
|
||||
runtime = new PodmanRuntime({ podmanPath })
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getPodmanRuntime(): PodmanRuntime {
|
||||
if (!runtime) runtime = new PodmanRuntime()
|
||||
return runtime
|
||||
}
|
||||
@@ -21,6 +21,17 @@ interface RuntimeState {
|
||||
gatewayPort: number
|
||||
}
|
||||
|
||||
function readForcedGatewayPort(): number | null {
|
||||
const raw = process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT?.trim()
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getRuntimeStatePath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), RUNTIME_STATE_FILE)
|
||||
}
|
||||
@@ -87,6 +98,12 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
||||
export async function allocateGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number> {
|
||||
const forcedPort = readForcedGatewayPort()
|
||||
if (forcedPort !== null) {
|
||||
await writePersistedGatewayPort(openclawDir, forcedPort)
|
||||
return forcedPort
|
||||
}
|
||||
|
||||
const persisted = await readPersistedGatewayPort(openclawDir)
|
||||
if (persisted !== null && (await isPortAvailable(persisted))) {
|
||||
return persisted
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { buildNerdctlCommand } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
|
||||
@@ -11,7 +12,9 @@ const TERMINAL_NAME = 'xterm-256color'
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
containerName: string
|
||||
podmanPath: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
workingDir: string
|
||||
onExit: (exitCode: number) => void
|
||||
onOutput: (data: string) => void
|
||||
@@ -24,32 +27,44 @@ export interface TerminalSession {
|
||||
}
|
||||
|
||||
export function buildTerminalExecCommand(
|
||||
podmanPath: string,
|
||||
limactlPath: string,
|
||||
vmName: string,
|
||||
containerName: string,
|
||||
workingDir: string,
|
||||
): string[] {
|
||||
return [
|
||||
podmanPath,
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
limactlPath,
|
||||
'shell',
|
||||
vmName,
|
||||
'--',
|
||||
...buildNerdctlCommand([
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildTerminalEnv(limaHome: string): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: limaHome, TERM: TERMINAL_NAME }
|
||||
}
|
||||
|
||||
export function createTerminalSession(
|
||||
deps: TerminalSessionDeps,
|
||||
): TerminalSession {
|
||||
const decoder = new TextDecoder()
|
||||
const proc = Bun.spawn(
|
||||
buildTerminalExecCommand(
|
||||
deps.podmanPath,
|
||||
deps.limactlPath,
|
||||
deps.vmName,
|
||||
deps.containerName,
|
||||
deps.workingDir,
|
||||
),
|
||||
{
|
||||
cwd: '/',
|
||||
terminal: {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
@@ -58,7 +73,7 @@ export function createTerminalSession(
|
||||
if (chunk) deps.onOutput(chunk)
|
||||
},
|
||||
},
|
||||
env: { ...process.env, TERM: TERMINAL_NAME },
|
||||
env: buildTerminalEnv(deps.limaHome),
|
||||
},
|
||||
)
|
||||
let closed = false
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { Command, InvalidArgumentError } from 'commander'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -30,6 +31,8 @@ export const ServerConfigSchema = z.object({
|
||||
instanceBrowserosVersion: z.string().optional(),
|
||||
instanceChromiumVersion: z.string().optional(),
|
||||
aiSdkDevtoolsEnabled: z.boolean(),
|
||||
vmCachePrefetch: z.boolean(),
|
||||
vmCacheManifestUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export type ServerConfig = z.infer<typeof ServerConfigSchema>
|
||||
@@ -226,6 +229,11 @@ function parseConfigFile(filePath?: string): ConfigResult<PartialConfig> {
|
||||
cfg.flags?.allow_remote_in_mcp === true ? true : undefined,
|
||||
aiSdkDevtoolsEnabled:
|
||||
cfg.flags?.ai_sdk_devtools === true ? true : undefined,
|
||||
vmCachePrefetch:
|
||||
typeof cfg.vm_cache?.prefetch === 'boolean'
|
||||
? cfg.vm_cache.prefetch
|
||||
: undefined,
|
||||
vmCacheManifestUrl: parseTrimmedString(cfg.vm_cache?.manifest_url),
|
||||
instanceClientId:
|
||||
typeof cfg.instance?.client_id === 'string'
|
||||
? cfg.instance.client_id
|
||||
@@ -272,6 +280,10 @@ function parseRuntimeEnv(): PartialConfig {
|
||||
instanceClientId: process.env.BROWSEROS_CLIENT_ID,
|
||||
aiSdkDevtoolsEnabled:
|
||||
process.env.BROWSEROS_AI_SDK_DEVTOOLS === 'true' ? true : undefined,
|
||||
vmCachePrefetch: parseBooleanEnv(process.env.BROWSEROS_VM_CACHE_PREFETCH),
|
||||
vmCacheManifestUrl: parseTrimmedString(
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -305,6 +317,8 @@ function getDefaults(cwd: string): PartialConfig {
|
||||
executionDir: cwd,
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +339,18 @@ function safeParseInt(value: string): number | undefined {
|
||||
return Number.isNaN(num) ? undefined : num
|
||||
}
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function omitUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, v]) => v !== undefined),
|
||||
|
||||
@@ -19,6 +19,8 @@ export const INLINED_ENV = {
|
||||
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
|
||||
BROWSEROS_VM_CACHE_PREFETCH: process.env.BROWSEROS_VM_CACHE_PREFETCH,
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL: process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
|
||||
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
|
||||
} as const
|
||||
|
||||
@@ -27,4 +29,6 @@ export const REQUIRED_FOR_PRODUCTION = [
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'BROWSEROS_CONFIG_URL',
|
||||
'BROWSEROS_VM_CACHE_PREFETCH',
|
||||
'BROWSEROS_VM_CACHE_MANIFEST_URL',
|
||||
] as const satisfies readonly (keyof typeof INLINED_ENV)[]
|
||||
|
||||
@@ -7,6 +7,10 @@ import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-confi
|
||||
import { logger } from './logger'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
const override = process.env.BROWSEROS_DIR?.trim()
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
@@ -44,6 +48,10 @@ export function getBuiltinSkillsDir(): string {
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
return join(getVmStateDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getLegacyOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
@@ -55,6 +63,18 @@ export function getVmCacheDir(): string {
|
||||
return join(getCacheDir(), 'vm')
|
||||
}
|
||||
|
||||
export function getLimaHomeDir(): string {
|
||||
return join(getBrowserosDir(), 'lima')
|
||||
}
|
||||
|
||||
export function getVmStateDir(): string {
|
||||
return join(getBrowserosDir(), 'vm')
|
||||
}
|
||||
|
||||
export function getVmDisksDir(): string {
|
||||
return getVmCacheDir()
|
||||
}
|
||||
|
||||
export function getAgentCacheDir(): string {
|
||||
return join(getVmCacheDir(), 'images')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ContainerCliError } from '../vm/errors'
|
||||
import { LimaCli } from '../vm/lima-cli'
|
||||
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
|
||||
|
||||
export function buildNerdctlCommand(args: string[]): string[] {
|
||||
return ['nerdctl', ...args]
|
||||
}
|
||||
|
||||
export interface ContainerCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
vmName: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
export interface ContainerCommandResult {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export class ContainerCli {
|
||||
private readonly lima: LimaCli
|
||||
|
||||
constructor(private readonly cfg: ContainerCliConfig) {
|
||||
this.lima = new LimaCli({
|
||||
limactlPath: cfg.limactlPath,
|
||||
limaHome: cfg.limaHome,
|
||||
sshPath: cfg.sshPath,
|
||||
})
|
||||
}
|
||||
|
||||
async imageExists(ref: string): Promise<boolean> {
|
||||
const result = await this.runCommand(['image', 'inspect', ref])
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['pull', ref], onLog)
|
||||
}
|
||||
|
||||
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
|
||||
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
|
||||
return parseLoadedImageRefs(result.stdout)
|
||||
}
|
||||
|
||||
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(buildCreateArgs(spec), onLog)
|
||||
}
|
||||
|
||||
async startContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['start', name], onLog)
|
||||
}
|
||||
|
||||
async stopContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
const result = await this.runCommand(['stop', name], onLog)
|
||||
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
|
||||
throw this.commandError(['stop', name], result)
|
||||
}
|
||||
|
||||
async removeContainer(
|
||||
name: string,
|
||||
opts?: { force?: boolean },
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
const args = ['rm']
|
||||
if (opts?.force) args.push('-f')
|
||||
args.push(name)
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
|
||||
const result = await this.runCommand(['exec', name, ...cmd], onLog)
|
||||
return result.exitCode
|
||||
}
|
||||
|
||||
async ps(opts?: { namesOnly?: boolean }): Promise<string[]> {
|
||||
const args = opts?.namesOnly ? ['ps', '--format', '{{.Names}}'] : ['ps']
|
||||
const result = await this.runRequired(args)
|
||||
return result.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
tailLogs(name: string, onLine: LogFn): () => void {
|
||||
const proc = this.lima.spawnShell(
|
||||
this.cfg.vmName,
|
||||
buildNerdctlCommand(['logs', '-f', '-n', '0', name]),
|
||||
{ onStdout: onLine, onStderr: onLine },
|
||||
)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
const stdoutLines: string[] = []
|
||||
const stderrLines: string[] = []
|
||||
const exitCode = await this.lima.shell(
|
||||
this.cfg.vmName,
|
||||
buildNerdctlCommand(args),
|
||||
{
|
||||
onStdout: (line) => {
|
||||
stdoutLines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
onStderr: (line) => {
|
||||
stderrLines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout: linesToOutput(stdoutLines),
|
||||
stderr: stderrLines.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
private async runRequired(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0) return result
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
private commandError(
|
||||
args: string[],
|
||||
result: ContainerCommandResult,
|
||||
): ContainerCliError {
|
||||
return new ContainerCliError(
|
||||
`nerdctl ${args.join(' ')}`,
|
||||
result.exitCode,
|
||||
result.stderr.trim(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildCreateArgs(spec: ContainerSpec): string[] {
|
||||
const args = ['create', '--name', spec.name]
|
||||
|
||||
if (spec.restart) args.push('--restart', spec.restart)
|
||||
for (const port of spec.ports ?? []) args.push('-p', portArg(port))
|
||||
if (spec.envFile) args.push('--env-file', spec.envFile)
|
||||
for (const [key, value] of Object.entries(spec.env ?? {})) {
|
||||
args.push('-e', `${key}=${value}`)
|
||||
}
|
||||
for (const mount of spec.mounts ?? []) args.push('-v', mountArg(mount))
|
||||
for (const host of spec.addHosts ?? []) args.push('--add-host', host)
|
||||
if (spec.health) {
|
||||
args.push('--health-cmd', spec.health.cmd)
|
||||
if (spec.health.interval)
|
||||
args.push('--health-interval', spec.health.interval)
|
||||
if (spec.health.timeout) args.push('--health-timeout', spec.health.timeout)
|
||||
if (spec.health.retries !== undefined) {
|
||||
args.push('--health-retries', String(spec.health.retries))
|
||||
}
|
||||
}
|
||||
|
||||
args.push(spec.image)
|
||||
args.push(...(spec.command ?? []))
|
||||
return args
|
||||
}
|
||||
|
||||
function portArg(port: PortMapping): string {
|
||||
const host = port.hostIp ? `${port.hostIp}:${port.hostPort}` : port.hostPort
|
||||
return `${host}:${port.containerPort}`
|
||||
}
|
||||
|
||||
function mountArg(mount: MountSpec): string {
|
||||
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
|
||||
}
|
||||
|
||||
function parseLoadedImageRefs(stdout: string): string[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
|
||||
.filter((ref): ref is string => !!ref)
|
||||
}
|
||||
|
||||
function isNoSuchContainer(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return lower.includes('no such container') || lower.includes('not found')
|
||||
}
|
||||
|
||||
function linesToOutput(lines: string[]): string {
|
||||
if (lines.length === 0) return ''
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { basename, join } from 'node:path'
|
||||
import { ContainerCliError, ImageLoadError } from '../vm/errors'
|
||||
import type { VmManifest } from '../vm/manifest'
|
||||
import type { Arch } from '../vm/paths'
|
||||
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
|
||||
import type { ContainerCli } from './container-cli'
|
||||
import type { LogFn } from './types'
|
||||
|
||||
export class ImageLoader {
|
||||
constructor(
|
||||
private readonly cli: ContainerCli,
|
||||
private readonly manifest: VmManifest,
|
||||
private readonly arch: Arch,
|
||||
private readonly browserosRoot?: string,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
|
||||
if (await this.cli.imageExists(ref)) return
|
||||
|
||||
const tarball = this.resolveTarball(ref)
|
||||
const hostPath = join(
|
||||
getImageCacheDir(this.browserosRoot),
|
||||
basename(tarball.key),
|
||||
)
|
||||
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
|
||||
|
||||
try {
|
||||
await this.cli.loadImage(guestPath, onLog)
|
||||
} catch (error) {
|
||||
if (error instanceof ContainerCliError) {
|
||||
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!(await this.cli.imageExists(ref))) {
|
||||
throw new ImageLoadError(
|
||||
ref,
|
||||
`image not present after successful load of ${guestPath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTarball(
|
||||
ref: string,
|
||||
): VmManifest['agents'][string]['tarballs'][Arch] {
|
||||
for (const agent of Object.values(this.manifest.agents)) {
|
||||
if (`${agent.image}:${agent.version}` !== ref) continue
|
||||
const tarball = agent.tarballs[this.arch]
|
||||
if (!tarball) {
|
||||
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
|
||||
}
|
||||
return tarball
|
||||
}
|
||||
|
||||
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './container-cli'
|
||||
export * from './image-loader'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
export interface PortMapping {
|
||||
hostIp?: string
|
||||
hostPort: number
|
||||
containerPort: number
|
||||
}
|
||||
|
||||
export interface MountSpec {
|
||||
source: string
|
||||
target: string
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export interface HealthConfig {
|
||||
cmd: string
|
||||
interval?: string
|
||||
timeout?: string
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export interface ContainerSpec {
|
||||
name: string
|
||||
image: string
|
||||
restart?: 'no' | 'unless-stopped' | 'always'
|
||||
ports?: PortMapping[]
|
||||
env?: Record<string, string>
|
||||
envFile?: string
|
||||
mounts?: MountSpec[]
|
||||
addHosts?: string[]
|
||||
health?: HealthConfig
|
||||
command?: string[]
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
}
|
||||
322
packages/browseros-agent/apps/server/src/lib/vm/cache-sync.ts
Normal file
322
packages/browseros-agent/apps/server/src/lib/vm/cache-sync.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { arch as hostArch } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import type { VmArtifact, VmManifest } from './manifest'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath } from './paths'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
const ARCHES: Arch[] = ['arm64', 'x64']
|
||||
const CANONICAL_MANIFEST_SUFFIX = '/vm/manifest.json'
|
||||
|
||||
export interface VmCacheSyncOptions {
|
||||
browserosRoot?: string
|
||||
manifestUrl?: string
|
||||
allArches?: boolean
|
||||
fetchImpl?: typeof fetch
|
||||
rawHostArch?: NodeJS.Architecture
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface VmCacheSyncResult {
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, Promise<VmCacheSyncResult>>()
|
||||
|
||||
export function prefetchVmCache(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<VmCacheSyncResult> {
|
||||
return startOrReuseSync(options)
|
||||
}
|
||||
|
||||
export function ensureVmCacheSynced(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<VmCacheSyncResult> {
|
||||
return startOrReuseSync(options)
|
||||
}
|
||||
|
||||
export async function ensureVmCacheAvailable(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<void> {
|
||||
const cfg = resolveSyncConfig(options)
|
||||
const pending = inFlight.get(syncKey(cfg))
|
||||
if (pending) {
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
|
||||
if (existsSync(getCachedManifestPath(cfg.browserosRoot))) return
|
||||
|
||||
await startOrReuseSyncWithConfig(cfg)
|
||||
}
|
||||
|
||||
function startOrReuseSync(
|
||||
options: VmCacheSyncOptions,
|
||||
): Promise<VmCacheSyncResult> {
|
||||
try {
|
||||
return startOrReuseSyncWithConfig(resolveSyncConfig(options))
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function startOrReuseSyncWithConfig(
|
||||
cfg: SyncConfig,
|
||||
): Promise<VmCacheSyncResult> {
|
||||
const key = syncKey(cfg)
|
||||
const existing = inFlight.get(key)
|
||||
if (existing) return existing
|
||||
const current = syncVmCache(cfg).finally(() => {
|
||||
if (inFlight.get(key) === current) inFlight.delete(key)
|
||||
})
|
||||
inFlight.set(key, current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function syncVmCache(cfg: SyncConfig): Promise<VmCacheSyncResult> {
|
||||
const remote = await fetchManifest(cfg)
|
||||
const manifestPath = getCachedManifestPath(cfg.browserosRoot)
|
||||
const local = await readLocalManifest(manifestPath)
|
||||
const plan = await planDownloads({
|
||||
remote,
|
||||
local,
|
||||
cacheRoot: cacheRootForManifest(manifestPath),
|
||||
arches: cfg.arches,
|
||||
})
|
||||
|
||||
for (const item of plan) {
|
||||
await downloadArtifact(
|
||||
cfg.fetchImpl,
|
||||
artifactUrlForKey(cfg.manifestUrl, item.key),
|
||||
item.destPath,
|
||||
item.sha256,
|
||||
cfg.timeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await Bun.write(tempPath, `${JSON.stringify(remote, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
|
||||
return {
|
||||
downloaded: plan.map((item) => item.key),
|
||||
manifestPath,
|
||||
skipped: plan.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncConfig {
|
||||
browserosRoot?: string
|
||||
manifestUrl: string
|
||||
fetchImpl: typeof fetch
|
||||
arches: Arch[]
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function resolveSyncConfig(options: VmCacheSyncOptions): SyncConfig {
|
||||
return {
|
||||
browserosRoot: options.browserosRoot,
|
||||
manifestUrl:
|
||||
trimNonEmpty(options.manifestUrl) ??
|
||||
trimNonEmpty(process.env.BROWSEROS_VM_CACHE_MANIFEST_URL) ??
|
||||
EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
fetchImpl: options.fetchImpl ?? fetch,
|
||||
arches: selectSyncArches(options),
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManifest(cfg: SyncConfig): Promise<VmManifest> {
|
||||
const response = await fetchWithTimeout(
|
||||
cfg.fetchImpl,
|
||||
cfg.manifestUrl,
|
||||
cfg.timeoutMs,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`manifest fetch failed: ${cfg.manifestUrl} (${response.status})`,
|
||||
)
|
||||
}
|
||||
return (await response.json()) as VmManifest
|
||||
}
|
||||
|
||||
interface DownloadPlanItem {
|
||||
key: string
|
||||
destPath: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
async function planDownloads(opts: {
|
||||
remote: VmManifest
|
||||
local: VmManifest | null
|
||||
cacheRoot: string
|
||||
arches: Arch[]
|
||||
}): Promise<DownloadPlanItem[]> {
|
||||
const out: DownloadPlanItem[] = []
|
||||
for (const arch of opts.arches) {
|
||||
for (const [name, agent] of Object.entries(opts.remote.agents)) {
|
||||
const remote = agent.tarballs[arch]
|
||||
if (!remote) continue
|
||||
const destPath = join(opts.cacheRoot, remote.key)
|
||||
if (
|
||||
!(await needsDownload(
|
||||
remote,
|
||||
opts.local?.agents[name]?.tarballs[arch],
|
||||
destPath,
|
||||
))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
out.push({ key: remote.key, destPath, sha256: remote.sha256 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function needsDownload(
|
||||
remote: VmArtifact,
|
||||
local: VmArtifact | undefined,
|
||||
destPath: string,
|
||||
): Promise<boolean> {
|
||||
if (!existsSync(destPath)) return true
|
||||
if (local?.sha256 === remote.sha256) return false
|
||||
try {
|
||||
return (await sha256File(destPath)) !== remote.sha256
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
destPath: string,
|
||||
sha256: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const partialPath = `${destPath}.partial`
|
||||
await mkdir(dirname(destPath), { recursive: true })
|
||||
await rm(partialPath, { force: true })
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`download failed: ${url} (${response.status})`)
|
||||
}
|
||||
|
||||
const sink = Bun.file(partialPath).writer()
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
sink.write(value)
|
||||
}
|
||||
} finally {
|
||||
await sink.end()
|
||||
}
|
||||
|
||||
await verifySha256(partialPath, sha256)
|
||||
await rename(partialPath, destPath)
|
||||
} catch (error) {
|
||||
await rm(partialPath, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
return await fetchImpl(url, { signal: controller.signal })
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySha256(path: string, expected: string): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function readLocalManifest(path: string): Promise<VmManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function selectSyncArches(options: VmCacheSyncOptions): Arch[] {
|
||||
if (options.allArches) return [...ARCHES]
|
||||
const rawArch = options.rawHostArch ?? hostArch()
|
||||
if (rawArch === 'arm64') return ['arm64']
|
||||
if (rawArch === 'x64' || rawArch === 'ia32') return ['x64']
|
||||
throw new Error(`unsupported host arch: ${rawArch}`)
|
||||
}
|
||||
|
||||
function cacheRootForManifest(manifestPath: string): string {
|
||||
return dirname(dirname(manifestPath))
|
||||
}
|
||||
|
||||
function syncKey(cfg: SyncConfig): string {
|
||||
return [
|
||||
getCachedManifestPath(cfg.browserosRoot),
|
||||
cfg.manifestUrl,
|
||||
cfg.arches.join(','),
|
||||
String(cfg.timeoutMs),
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
function artifactUrlForKey(manifestUrl: string, key: string): string {
|
||||
const artifactKey = key.replace(/^\/+/, '')
|
||||
const url = new URL(manifestUrl)
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '')
|
||||
const prefix = normalizedPath.endsWith(CANONICAL_MANIFEST_SUFFIX)
|
||||
? normalizedPath.slice(0, -CANONICAL_MANIFEST_SUFFIX.length)
|
||||
: normalizedPath.slice(0, Math.max(0, normalizedPath.lastIndexOf('/')))
|
||||
|
||||
url.pathname = `${prefix.replace(/\/+$/, '')}/${artifactKey}`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
60
packages/browseros-agent/apps/server/src/lib/vm/errors.ts
Normal file
60
packages/browseros-agent/apps/server/src/lib/vm/errors.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export class VmError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
|
||||
export class VmNotReadyError extends VmError {}
|
||||
|
||||
export class VmStateCorruptedError extends VmError {}
|
||||
|
||||
export class LimaCommandError extends VmError {
|
||||
constructor(
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainerCliError extends VmError {
|
||||
constructor(
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageLoadError extends VmError {
|
||||
constructor(
|
||||
public readonly imageRef: string,
|
||||
message: string,
|
||||
public override readonly cause?: unknown,
|
||||
) {
|
||||
super(`failed to load image ${imageRef}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ManifestMissingError extends VmError {
|
||||
constructor(public readonly manifestPath: string) {
|
||||
super(manifestMissingMessage(manifestPath))
|
||||
}
|
||||
}
|
||||
|
||||
function manifestMissingMessage(manifestPath: string): string {
|
||||
const message = `VM manifest is missing at ${manifestPath}`
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${message}; run bun run dev:setup before starting the server`
|
||||
}
|
||||
return message
|
||||
}
|
||||
13
packages/browseros-agent/apps/server/src/lib/vm/index.ts
Normal file
13
packages/browseros-agent/apps/server/src/lib/vm/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './errors'
|
||||
export * from './lima-cli'
|
||||
export * from './lima-config'
|
||||
export * from './manifest'
|
||||
export * from './paths'
|
||||
export * from './telemetry'
|
||||
export * from './vm-runtime'
|
||||
270
packages/browseros-agent/apps/server/src/lib/vm/lima-cli.ts
Normal file
270
packages/browseros-agent/apps/server/src/lib/vm/lima-cli.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { logger } from '../logger'
|
||||
import { LimaCommandError, VmNotReadyError } from './errors'
|
||||
import { getLimaSshConfigPath } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export interface LimaListEntry {
|
||||
name: string
|
||||
status: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
export interface LimaCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
export interface LimaShellStreams {
|
||||
onStdout?: (line: string) => void
|
||||
onStderr?: (line: string) => void
|
||||
}
|
||||
|
||||
export interface LimaShellProcess {
|
||||
kill: () => void
|
||||
exited: Promise<number>
|
||||
}
|
||||
|
||||
const LIMA_VERBOSE_LOGGING = false
|
||||
|
||||
export class LimaCli {
|
||||
constructor(private readonly cfg: LimaCliConfig) {}
|
||||
|
||||
async list(): Promise<LimaListEntry[]> {
|
||||
const result = await this.run(['list', '--format', 'json'])
|
||||
if (!result.stdout.trim()) {
|
||||
logger.debug('Lima list returned no instances', {
|
||||
limaHome: this.cfg.limaHome,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const entries = parseLimaList(result.stdout)
|
||||
logger.debug('Lima list parsed', {
|
||||
limaHome: this.cfg.limaHome,
|
||||
count: entries.length,
|
||||
entries: entries.map((e) => ({ name: e.name, status: e.status })),
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
async create(name: string, yamlPath: string): Promise<void> {
|
||||
await this.runChecked('create', [
|
||||
'create',
|
||||
'--tty=false',
|
||||
`--name=${name}`,
|
||||
yamlPath,
|
||||
])
|
||||
}
|
||||
|
||||
async start(name: string): Promise<void> {
|
||||
logger.info('Invoking limactl start', {
|
||||
vmName: name,
|
||||
limaHome: this.cfg.limaHome,
|
||||
note: 'this command blocks until boot reaches READY; may take 40-120s on first boot',
|
||||
})
|
||||
await this.runChecked('start', ['start', '--tty=false', name])
|
||||
}
|
||||
|
||||
async stop(name: string): Promise<void> {
|
||||
await this.runChecked('stop', ['stop', name])
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.runChecked('delete', ['delete', '--force', name])
|
||||
}
|
||||
|
||||
async shell(
|
||||
name: string,
|
||||
args: string[],
|
||||
streams?: LimaShellStreams,
|
||||
): Promise<number> {
|
||||
const proc = this.spawnShell(name, args, streams)
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
spawnShell(
|
||||
name: string,
|
||||
args: string[],
|
||||
streams?: LimaShellStreams,
|
||||
): LimaShellProcess {
|
||||
const configPath = getLimaSshConfigPath(this.cfg.limaHome, name)
|
||||
if (!existsSync(configPath)) {
|
||||
throw new VmNotReadyError(
|
||||
`lima ssh.config not found at ${configPath}; VM has not been started`,
|
||||
)
|
||||
}
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.cfg.sshPath ?? 'ssh',
|
||||
'-F',
|
||||
configPath,
|
||||
`lima-${name}`,
|
||||
shellQuoteCommand(args),
|
||||
],
|
||||
{
|
||||
cwd: '/',
|
||||
env: this.env(),
|
||||
stdout: streams?.onStdout ? 'pipe' : 'ignore',
|
||||
stderr: streams?.onStderr ? 'pipe' : 'ignore',
|
||||
},
|
||||
)
|
||||
|
||||
const drained = Promise.all([
|
||||
drainStream(proc.stdout ?? null, streams?.onStdout),
|
||||
drainStream(proc.stderr ?? null, streams?.onStderr),
|
||||
])
|
||||
const exited = drained.then(() => proc.exited)
|
||||
return {
|
||||
exited,
|
||||
kill: () => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async runChecked(command: string, args: string[]): Promise<void> {
|
||||
const result = await this.run(args)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new LimaCommandError(
|
||||
`limactl ${command}`,
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async run(args: string[]): Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}> {
|
||||
const started = Date.now()
|
||||
const proc = Bun.spawn([this.cfg.limactlPath, ...args], {
|
||||
env: this.env(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaSpawn, {
|
||||
pid: proc.pid,
|
||||
args,
|
||||
limaHome: this.cfg.limaHome,
|
||||
})
|
||||
|
||||
const stderrLogger = LIMA_VERBOSE_LOGGING
|
||||
? (line: string) => {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
line,
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
drainToString(proc.stdout),
|
||||
drainToString(proc.stderr, stderrLogger),
|
||||
proc.exited,
|
||||
])
|
||||
const durationMs = Date.now() - started
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaExit, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
exitCode,
|
||||
durationMs,
|
||||
stdoutLen: stdout.length,
|
||||
stderrLen: stderr.length,
|
||||
})
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
private env(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: this.cfg.limaHome }
|
||||
}
|
||||
}
|
||||
|
||||
async function drainToString(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<string> {
|
||||
if (!stream) return ''
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let output = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
output += chunk
|
||||
buffer += chunk
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && onLine) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim() && onLine) onLine(buffer.trim())
|
||||
return output
|
||||
}
|
||||
|
||||
function parseLimaList(output: string): LimaListEntry[] {
|
||||
const trimmed = output.trim()
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (Array.isArray(parsed)) return parsed.map(toLimaListEntry)
|
||||
return [toLimaListEntry(parsed)]
|
||||
} catch {
|
||||
return trimmed.split('\n').map((line) => toLimaListEntry(JSON.parse(line)))
|
||||
}
|
||||
}
|
||||
|
||||
function toLimaListEntry(input: unknown): LimaListEntry {
|
||||
const entry = input as Partial<LimaListEntry>
|
||||
return {
|
||||
name: entry.name ?? '',
|
||||
status: entry.status ?? '',
|
||||
dir: entry.dir ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuoteCommand(args: string[]): string {
|
||||
return args.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
function shellQuote(arg: string): string {
|
||||
return `'${arg.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream || !onLine) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (line.trim()) onLine(line.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function renderLimaTemplate(
|
||||
template: string,
|
||||
cfg: {
|
||||
vmStateDir: string
|
||||
imageCacheDir: string
|
||||
},
|
||||
): string {
|
||||
const mounts = [
|
||||
'mounts:',
|
||||
`- location: "${cfg.vmStateDir}"`,
|
||||
' mountPoint: "/mnt/browseros/vm"',
|
||||
' writable: true',
|
||||
`- location: "${cfg.imageCacheDir}"`,
|
||||
' mountPoint: "/mnt/browseros/cache/images"',
|
||||
' writable: false',
|
||||
].join('\n')
|
||||
|
||||
if (!template.includes('mounts: []')) {
|
||||
throw new Error('BrowserOS VM Lima template is missing mounts: [] marker')
|
||||
}
|
||||
|
||||
return template.replace('mounts: []', mounts)
|
||||
}
|
||||
102
packages/browseros-agent/apps/server/src/lib/vm/manifest.ts
Normal file
102
packages/browseros-agent/apps/server/src/lib/vm/manifest.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { ManifestMissingError } from './errors'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
|
||||
|
||||
export interface VmArtifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface VmAgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, VmArtifact>
|
||||
}
|
||||
|
||||
export interface VmManifest {
|
||||
schemaVersion: number
|
||||
updatedAt: string
|
||||
agents: Record<string, VmAgentEntry>
|
||||
}
|
||||
|
||||
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
|
||||
|
||||
export async function readCachedManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest> {
|
||||
const manifestPath = getCachedManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function readInstalledManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest | null> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) return null
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function writeInstalledManifest(
|
||||
manifest: VmManifest,
|
||||
browserosRoot?: string,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
}
|
||||
|
||||
export function compareVersions(
|
||||
installed: VmManifest | null,
|
||||
cached: VmManifest,
|
||||
): VersionComparison {
|
||||
if (!installed) return 'fresh'
|
||||
const comparison = compareVersionStrings(
|
||||
installed.updatedAt,
|
||||
cached.updatedAt,
|
||||
)
|
||||
if (comparison === 0) return 'same'
|
||||
return comparison < 0 ? 'upgrade' : 'downgrade'
|
||||
}
|
||||
|
||||
export function agentForArch(
|
||||
manifest: VmManifest,
|
||||
name: string,
|
||||
arch: Arch,
|
||||
): {
|
||||
image: string
|
||||
version: string
|
||||
tarball: VmManifest['agents'][string]['tarballs'][Arch]
|
||||
} {
|
||||
const agent = manifest.agents[name]
|
||||
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
|
||||
const tarball = agent.tarballs[arch]
|
||||
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
|
||||
return {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarball,
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(path: string): Promise<VmManifest> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
}
|
||||
|
||||
function compareVersionStrings(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
241
packages/browseros-agent/apps/server/src/lib/vm/paths.ts
Normal file
241
packages/browseros-agent/apps/server/src/lib/vm/paths.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { accessSync, constants, existsSync } from 'node:fs'
|
||||
import { homedir, arch as osArch } from 'node:os'
|
||||
import {
|
||||
delimiter,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
|
||||
export const VM_NAME = 'browseros-vm'
|
||||
export const GUEST_VM_STATE = '/mnt/browseros/vm'
|
||||
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
|
||||
const HOST_LIMACTL_BINARY = 'limactl'
|
||||
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
function rootDir(): string {
|
||||
const override = process.env.BROWSEROS_DIR?.trim()
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
const base =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), base)
|
||||
}
|
||||
|
||||
export function detectArch(arch: NodeJS.Architecture = osArch()): Arch {
|
||||
if (arch === 'arm64') return 'arm64'
|
||||
if (arch === 'x64') return 'x64'
|
||||
throw new Error(`unsupported host arch: ${arch}`)
|
||||
}
|
||||
|
||||
export function getLimaHomeDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, 'lima')
|
||||
}
|
||||
|
||||
export function getVmStateDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, 'vm')
|
||||
}
|
||||
|
||||
export function getVmCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
|
||||
}
|
||||
|
||||
export function getImageCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'images')
|
||||
}
|
||||
|
||||
export function getCachedManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmStateDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
|
||||
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
|
||||
}
|
||||
|
||||
export function getLimaSocketPath(browserosRoot = rootDir()): string {
|
||||
return getContainerdSocketPath(browserosRoot)
|
||||
}
|
||||
|
||||
export function getLimaSshConfigPath(limaHome: string, name: string): string {
|
||||
return join(limaHome, name, 'ssh.config')
|
||||
}
|
||||
|
||||
export function compressedDiskPath(
|
||||
version: string,
|
||||
arch: Arch,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
return join(
|
||||
getVmCacheDir(browserosRoot),
|
||||
`browseros-vm-${version}-${arch}.qcow2.zst`,
|
||||
)
|
||||
}
|
||||
|
||||
export function decompressedDiskPath(
|
||||
version: string,
|
||||
arch: Arch,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
return join(
|
||||
getVmCacheDir(browserosRoot),
|
||||
`browseros-vm-${version}-${arch}.qcow2`,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveBundledLimactl(
|
||||
resourcesDir: string,
|
||||
hostArch: Arch = detectArch(),
|
||||
): string {
|
||||
if (usesHostVmTools()) return resolveHostLimactl()
|
||||
|
||||
const limaRoot = resolveBundledLimaRoot(resourcesDir)
|
||||
const candidate = join(limaRoot, 'bin', 'limactl')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
assertBundledLimaGuestAgent(limaRoot, hostArch)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function resolveBundledLimaRoot(resourcesDir: string): string {
|
||||
return join(resourcesDir, 'bin', 'third_party', 'lima')
|
||||
}
|
||||
|
||||
function nativeLinuxGuestAgentName(arch: Arch): string {
|
||||
return arch === 'arm64'
|
||||
? 'lima-guestagent.Linux-aarch64.gz'
|
||||
: 'lima-guestagent.Linux-x86_64.gz'
|
||||
}
|
||||
|
||||
function assertBundledLimaGuestAgent(limaRoot: string, hostArch: Arch): void {
|
||||
const guestAgent = join(
|
||||
limaRoot,
|
||||
'share',
|
||||
'lima',
|
||||
nativeLinuxGuestAgentName(hostArch),
|
||||
)
|
||||
if (!existsSync(guestAgent)) {
|
||||
throw new Error(
|
||||
`bundled Lima guest agent not found at ${guestAgent}; upload Lima runtime files and refresh server resources`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHostLimactl(): string {
|
||||
const resolved = findExecutableOnPath(HOST_LIMACTL_BINARY)
|
||||
if (resolved) return resolved
|
||||
throw new Error(
|
||||
'Lima is not installed or limactl is not on PATH. Install with brew install lima.',
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveBundledLimaTemplate(resourcesDir: string): string {
|
||||
if (usesHostVmTools()) {
|
||||
const sourceTemplate = findSourceLimaTemplate(resourcesDir)
|
||||
if (sourceTemplate) return sourceTemplate
|
||||
}
|
||||
|
||||
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function usesHostVmTools(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
)
|
||||
}
|
||||
|
||||
function findExecutableOnPath(binary: string): string | null {
|
||||
const pathEnv = process.env.PATH
|
||||
if (!pathEnv) return null
|
||||
for (const dir of pathEnv.split(delimiter)) {
|
||||
if (!dir) continue
|
||||
const candidate = join(dir, binary)
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
return candidate
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findSourceLimaTemplate(resourcesDir: string): string | null {
|
||||
let current = resolve(resourcesDir)
|
||||
while (true) {
|
||||
const rootCandidate = join(
|
||||
current,
|
||||
'packages',
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
if (existsSync(rootCandidate)) return rootCandidate
|
||||
|
||||
const packageCandidate = join(
|
||||
current,
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
if (existsSync(packageCandidate)) return packageCandidate
|
||||
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return null
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export function hostPathToGuest(
|
||||
hostPath: string,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
const vmState = getVmStateDir(browserosRoot)
|
||||
const imageCache = getImageCacheDir(browserosRoot)
|
||||
const vmStateRelative = mountedRelativePath(vmState, hostPath)
|
||||
if (vmStateRelative !== null)
|
||||
return guestPath(GUEST_VM_STATE, vmStateRelative)
|
||||
|
||||
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
|
||||
if (imageCacheRelative !== null) {
|
||||
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
|
||||
}
|
||||
|
||||
throw new Error(`host path ${hostPath} is not under any known guest mount`)
|
||||
}
|
||||
|
||||
function mountedRelativePath(parent: string, child: string): string | null {
|
||||
const path = relative(parent, child)
|
||||
if (path === '') return ''
|
||||
if (path.startsWith('..') || isAbsolute(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
function guestPath(root: string, relativePath: string): string {
|
||||
if (!relativePath) return root
|
||||
return `${root}/${relativePath.split(sep).join('/')}`
|
||||
}
|
||||
36
packages/browseros-agent/apps/server/src/lib/vm/telemetry.ts
Normal file
36
packages/browseros-agent/apps/server/src/lib/vm/telemetry.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const VM_TELEMETRY_EVENTS = {
|
||||
ensureReadyStart: 'vm.ensure_ready.start',
|
||||
ensureReadyOk: 'vm.ensure_ready.ok',
|
||||
ensureReadyBranch: 'vm.ensure_ready.branch',
|
||||
create: 'vm.create',
|
||||
start: 'vm.start',
|
||||
stop: 'vm.stop',
|
||||
upgradeDetected: 'vm.upgrade.detected',
|
||||
downgradeDetected: 'vm.downgrade.detected',
|
||||
upgradeSwap: 'vm.upgrade.swap',
|
||||
upgradeReplay: 'vm.upgrade.replay',
|
||||
resetDetected: 'vm.reset.detected',
|
||||
resetOk: 'vm.reset.ok',
|
||||
nerdctlWaitStart: 'vm.nerdctl_wait.start',
|
||||
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
|
||||
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
|
||||
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
|
||||
manifestMissing: 'vm.manifest.missing',
|
||||
manifestCompared: 'vm.manifest.compared',
|
||||
manifestWritten: 'vm.manifest.written',
|
||||
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
|
||||
limaSpawn: 'vm.lima.spawn',
|
||||
limaExit: 'vm.lima.exit',
|
||||
limaStderrChunk: 'vm.lima.stderr_chunk',
|
||||
provisionYamlWrite: 'vm.provision.yaml_write',
|
||||
provisionCreateStart: 'vm.provision.create.start',
|
||||
provisionCreateOk: 'vm.provision.create.ok',
|
||||
provisionStartBegin: 'vm.provision.start.begin',
|
||||
provisionStartOk: 'vm.provision.start.ok',
|
||||
} as const
|
||||
336
packages/browseros-agent/apps/server/src/lib/vm/vm-runtime.ts
Normal file
336
packages/browseros-agent/apps/server/src/lib/vm/vm-runtime.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../logger'
|
||||
import { ensureVmCacheAvailable } from './cache-sync'
|
||||
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
|
||||
import { LimaCli } from './lima-cli'
|
||||
import { renderLimaTemplate } from './lima-config'
|
||||
import {
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
writeInstalledManifest,
|
||||
} from './manifest'
|
||||
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
const ROOTLESS_CONTAINERD_MARKER = 'runtime:containerd-rootless'
|
||||
|
||||
export interface VmRuntimeDeps {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
templatePath?: string
|
||||
browserosRoot?: string
|
||||
readinessTimeoutMs?: number
|
||||
readinessPollMs?: number
|
||||
ensureCacheAvailable?: () => Promise<void>
|
||||
}
|
||||
|
||||
export class VmRuntime {
|
||||
private readonly cli: LimaCli
|
||||
private readonly readinessTimeoutMs: number
|
||||
private readonly readinessPollMs: number
|
||||
private defaultGateway: string | null = null
|
||||
|
||||
constructor(private readonly deps: VmRuntimeDeps) {
|
||||
this.cli = new LimaCli({
|
||||
limactlPath: deps.limactlPath,
|
||||
limaHome: deps.limaHome,
|
||||
sshPath: deps.sshPath,
|
||||
})
|
||||
this.readinessTimeoutMs = deps.readinessTimeoutMs ?? 60_000
|
||||
this.readinessPollMs = deps.readinessPollMs ?? 500
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
const started = Date.now()
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyStart, {
|
||||
limaHome: this.deps.limaHome,
|
||||
browserosRoot: this.deps.browserosRoot,
|
||||
templatePath: this.deps.templatePath,
|
||||
limactlPath: this.deps.limactlPath,
|
||||
})
|
||||
|
||||
await this.ensureCacheAvailable()
|
||||
const cached = await readCachedManifest(this.deps.browserosRoot)
|
||||
const installed = await readInstalledManifest(this.deps.browserosRoot)
|
||||
const versionComparison = compareVersions(installed, cached)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
|
||||
versionComparison,
|
||||
installedUpdatedAt: installed?.updatedAt ?? null,
|
||||
cachedUpdatedAt: cached.updatedAt,
|
||||
})
|
||||
|
||||
const vms = await this.cli.list()
|
||||
const existing = vms.find((vm) => vm.name === VM_NAME)
|
||||
let shouldWriteInstalledManifest =
|
||||
!existing || versionComparison === 'fresh' || versionComparison === 'same'
|
||||
|
||||
let branch = !existing
|
||||
? 'provision-fresh'
|
||||
: existing.status !== 'Running'
|
||||
? 'start-existing'
|
||||
: versionComparison === 'upgrade'
|
||||
? 'running-upgrade-warn'
|
||||
: versionComparison === 'downgrade'
|
||||
? 'running-downgrade-warn'
|
||||
: 'running-same'
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
|
||||
branch,
|
||||
existingStatus: existing?.status ?? null,
|
||||
versionComparison,
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await this.provisionFresh(onLog)
|
||||
} else {
|
||||
if (existing.status !== 'Running') {
|
||||
onLog?.('Starting BrowserOS VM...')
|
||||
await this.cli.start(VM_NAME)
|
||||
}
|
||||
if (
|
||||
!(await this.isReady()) &&
|
||||
(await this.needsContainerdReprovision())
|
||||
) {
|
||||
branch = 'recreate-legacy-runtime'
|
||||
shouldWriteInstalledManifest = true
|
||||
await this.recreateForContainerd(onLog)
|
||||
} else if (versionComparison === 'upgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
} else if (versionComparison === 'downgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
|
||||
if (shouldWriteInstalledManifest) {
|
||||
await writeInstalledManifest(cached, this.deps.browserosRoot)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
|
||||
updatedAt: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
|
||||
durationMs: Date.now() - started,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
try {
|
||||
await this.cli.stop(VM_NAME)
|
||||
} catch (error) {
|
||||
if (error instanceof LimaCommandError && isAlreadyStopped(error.stderr)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
opts?: { onOutput?: LogFn },
|
||||
): Promise<number> {
|
||||
return this.cli.shell(VM_NAME, args, {
|
||||
onStdout: opts?.onOutput,
|
||||
onStderr: opts?.onOutput,
|
||||
})
|
||||
}
|
||||
|
||||
async reset(_reason: string): Promise<never> {
|
||||
throw notImplemented('VmRuntime.reset')
|
||||
}
|
||||
|
||||
async performUpgrade(): Promise<never> {
|
||||
throw notImplemented('VmRuntime.performUpgrade')
|
||||
}
|
||||
|
||||
async getDefaultGateway(): Promise<string> {
|
||||
if (this.defaultGateway) return this.defaultGateway
|
||||
|
||||
const lines: string[] = []
|
||||
const exitCode = await this.runCommand(
|
||||
['ip', '-4', 'route', 'show', 'default'],
|
||||
{
|
||||
onOutput: (line) => lines.push(line),
|
||||
},
|
||||
)
|
||||
if (exitCode !== 0) {
|
||||
throw new VmNotReadyError(
|
||||
`failed to resolve VM default gateway; ip route exited ${exitCode}`,
|
||||
)
|
||||
}
|
||||
|
||||
const gateway = parseDefaultGateway(lines.join('\n'))
|
||||
if (!gateway) {
|
||||
throw new VmNotReadyError('failed to resolve VM default gateway')
|
||||
}
|
||||
this.defaultGateway = gateway
|
||||
return gateway
|
||||
}
|
||||
|
||||
async isReady(): Promise<boolean> {
|
||||
return this.isRootlessNerdctlReady()
|
||||
}
|
||||
|
||||
getLimactlPath(): string {
|
||||
return this.deps.limactlPath
|
||||
}
|
||||
|
||||
private async provisionFresh(onLog?: LogFn): Promise<void> {
|
||||
this.defaultGateway = null
|
||||
const yaml = await this.buildLimaYaml()
|
||||
const yamlPath = join(this.deps.limaHome, `${VM_NAME}.yaml`)
|
||||
await mkdir(dirname(yamlPath), { recursive: true })
|
||||
await writeFile(yamlPath, yaml)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionYamlWrite, {
|
||||
yamlPath,
|
||||
yamlBytes: yaml.length,
|
||||
templatePath: this.deps.templatePath,
|
||||
})
|
||||
|
||||
onLog?.('Creating BrowserOS VM...')
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionCreateStart, { yamlPath })
|
||||
const createStarted = Date.now()
|
||||
await this.cli.create(VM_NAME, yamlPath)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionCreateOk, {
|
||||
durationMs: Date.now() - createStarted,
|
||||
})
|
||||
|
||||
onLog?.('Starting BrowserOS VM...')
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionStartBegin, {})
|
||||
const startStarted = Date.now()
|
||||
await this.cli.start(VM_NAME)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionStartOk, {
|
||||
durationMs: Date.now() - startStarted,
|
||||
})
|
||||
}
|
||||
|
||||
private async ensureCacheAvailable(): Promise<void> {
|
||||
if (this.deps.ensureCacheAvailable) {
|
||||
await this.deps.ensureCacheAvailable()
|
||||
return
|
||||
}
|
||||
await ensureVmCacheAvailable({ browserosRoot: this.deps.browserosRoot })
|
||||
}
|
||||
|
||||
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
|
||||
onLog?.('Recreating BrowserOS VM for containerd runtime...')
|
||||
try {
|
||||
await this.cli.stop(VM_NAME)
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof LimaCommandError) ||
|
||||
!isAlreadyStopped(error.stderr)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await this.cli.delete(VM_NAME)
|
||||
await this.provisionFresh(onLog)
|
||||
}
|
||||
|
||||
private async needsContainerdReprovision(): Promise<boolean> {
|
||||
const lines: string[] = []
|
||||
try {
|
||||
const exitCode = await this.runCommand(
|
||||
['sh', '-lc', 'cat /etc/browseros-vm-version 2>/dev/null || true'],
|
||||
{ onOutput: (line) => lines.push(line) },
|
||||
)
|
||||
if (exitCode !== 0) return false
|
||||
} catch (error) {
|
||||
logger.warn('Failed to inspect BrowserOS VM runtime marker', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return !lines.some((line) => line.trim() === ROOTLESS_CONTAINERD_MARKER)
|
||||
}
|
||||
|
||||
private async buildLimaYaml(): Promise<string> {
|
||||
if (!this.deps.templatePath) {
|
||||
throw new Error(
|
||||
'BrowserOS VM Lima template path is missing; configure VmRuntime with resourcesDir',
|
||||
)
|
||||
}
|
||||
|
||||
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
|
||||
vmStateDir: getVmStateDir(this.deps.browserosRoot),
|
||||
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForRootlessNerdctl(timeoutMs: number): Promise<void> {
|
||||
const started = Date.now()
|
||||
const deadline = started + timeoutMs
|
||||
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitStart, {
|
||||
timeoutMs,
|
||||
pollMs: this.readinessPollMs,
|
||||
})
|
||||
let pollCount = 0
|
||||
while (Date.now() < deadline) {
|
||||
pollCount += 1
|
||||
if (await this.isReady()) {
|
||||
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitOk, {
|
||||
pollCount,
|
||||
waitMs: Date.now() - started,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (pollCount === 1 || pollCount % 10 === 0) {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.nerdctlWaitPoll, {
|
||||
pollCount,
|
||||
elapsedMs: Date.now() - started,
|
||||
})
|
||||
}
|
||||
await Bun.sleep(this.readinessPollMs)
|
||||
}
|
||||
logger.error(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout, {
|
||||
timeoutMs,
|
||||
pollCount,
|
||||
})
|
||||
throw new VmNotReadyError('rootless nerdctl never became ready')
|
||||
}
|
||||
|
||||
private async isRootlessNerdctlReady(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.runCommand(['nerdctl', 'info'])) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notImplemented(feature: string): VmError {
|
||||
return new VmError(
|
||||
`${feature} is not implemented yet - see WS4 follow-up plan`,
|
||||
)
|
||||
}
|
||||
|
||||
function isAlreadyStopped(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return (
|
||||
lower.includes('not running') ||
|
||||
lower.includes('already stopped') ||
|
||||
lower.includes('not found')
|
||||
)
|
||||
}
|
||||
|
||||
function parseDefaultGateway(output: string): string | null {
|
||||
return output.match(/\bdefault\s+via\s+(\d+\.\d+\.\d+\.\d+)\b/)?.[1] ?? null
|
||||
}
|
||||
@@ -15,10 +15,9 @@ import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
|
||||
import { createHttpServer } from './api/server'
|
||||
import {
|
||||
configureOpenClawService,
|
||||
configureVmRuntime,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { loadPodmanOverrides } from './api/services/openclaw/podman-overrides'
|
||||
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
import type { ServerConfig } from './config'
|
||||
@@ -26,7 +25,6 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
getOpenClawDir,
|
||||
removeServerConfigSync,
|
||||
writeServerConfig,
|
||||
} from './lib/browseros-dir'
|
||||
@@ -37,6 +35,7 @@ import { metrics } from './lib/metrics'
|
||||
import { isPortInUseError } from './lib/port-binding'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { prefetchVmCache } from './lib/vm/cache-sync'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import {
|
||||
startSkillSync,
|
||||
@@ -62,16 +61,7 @@ export class Application {
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
|
||||
configurePodmanRuntime({
|
||||
resourcesDir,
|
||||
podmanPath: podmanOverrides.podmanPath ?? undefined,
|
||||
})
|
||||
if (podmanOverrides.podmanPath) {
|
||||
logger.info('Using user-overridden Podman binary', {
|
||||
podmanPath: podmanOverrides.podmanPath,
|
||||
})
|
||||
}
|
||||
configureVmRuntime({ resourcesDir, vmCache: this.vmCacheConfig() })
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -139,6 +129,7 @@ export class Application {
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir,
|
||||
vmCache: this.vmCacheConfig(),
|
||||
})
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
@@ -172,6 +163,7 @@ export class Application {
|
||||
private async initCoreServices(): Promise<void> {
|
||||
this.configureLogDirectory()
|
||||
await ensureBrowserosDir()
|
||||
this.startVmCachePrefetch()
|
||||
await cleanOldSessions()
|
||||
await seedSoulTemplate()
|
||||
await migrateBuiltinSkills()
|
||||
@@ -220,6 +212,25 @@ export class Application {
|
||||
})
|
||||
}
|
||||
|
||||
private startVmCachePrefetch(): void {
|
||||
if (!this.config.vmCachePrefetch) return
|
||||
void prefetchVmCache({
|
||||
manifestUrl: this.config.vmCacheManifestUrl,
|
||||
}).catch((error) => {
|
||||
logger.warn('BrowserOS VM cache prefetch failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private vmCacheConfig(): {
|
||||
manifestUrl: string
|
||||
} {
|
||||
return {
|
||||
manifestUrl: this.config.vmCacheManifestUrl,
|
||||
}
|
||||
}
|
||||
|
||||
private configureLogDirectory(): void {
|
||||
const logDir = this.config.executionDir
|
||||
const resolvedDir = path.isAbsolute(logDir)
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import type { MonitoringToolCallRecord } from '../types'
|
||||
import type {
|
||||
LazyMonitoringJudgeInput,
|
||||
LazyMonitoringJudgment,
|
||||
LazyMonitoringPolicyDimension,
|
||||
LazyMonitoringVerdict,
|
||||
} from './types'
|
||||
|
||||
const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
|
||||
const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-5.4-mini'
|
||||
const DEFAULT_APP_NAME = 'BrowserOS Lazy Monitoring Judge'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const MAX_STRING_LENGTH = 1_200
|
||||
const MAX_ARRAY_ITEMS = 8
|
||||
const MAX_OBJECT_KEYS = 20
|
||||
const ALLOWED_DIMENSIONS = new Set<LazyMonitoringPolicyDimension>([
|
||||
'communication_risk',
|
||||
'data_access',
|
||||
'destructive_action',
|
||||
'scope_mismatch',
|
||||
'unexpected_side_effect',
|
||||
])
|
||||
const ALLOWED_VERDICTS = new Set<LazyMonitoringVerdict>([
|
||||
'safe',
|
||||
'needs_review',
|
||||
'suspicious',
|
||||
'unsafe',
|
||||
])
|
||||
|
||||
export class LazyMonitoringJudgeError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'LazyMonitoringJudgeError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LazyMonitoringJudgeConfig {
|
||||
provider: 'openrouter' | 'openai-compatible'
|
||||
model: string
|
||||
baseUrl: string
|
||||
apiKey?: string
|
||||
timeoutMs: number
|
||||
siteUrl?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
export function resolveLazyMonitoringJudgeConfig(): LazyMonitoringJudgeConfig | null {
|
||||
if (process.env.BROWSEROS_LAZY_MONITORING_JUDGE_DISABLED === 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
const provider =
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_PROVIDER === 'openai-compatible'
|
||||
? 'openai-compatible'
|
||||
: 'openrouter'
|
||||
const model =
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_MODEL ??
|
||||
DEFAULT_OPENROUTER_MODEL
|
||||
const timeoutMs = Number.parseInt(
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_TIMEOUT_MS ?? '',
|
||||
10,
|
||||
)
|
||||
const config: LazyMonitoringJudgeConfig = {
|
||||
provider,
|
||||
model,
|
||||
baseUrl:
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_BASE_URL ??
|
||||
DEFAULT_OPENROUTER_BASE_URL,
|
||||
apiKey:
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_API_KEY ??
|
||||
(provider === 'openrouter' ? process.env.OPENROUTER_API_KEY : undefined),
|
||||
timeoutMs:
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: DEFAULT_TIMEOUT_MS,
|
||||
siteUrl: process.env.BROWSEROS_LAZY_MONITORING_JUDGE_SITE_URL,
|
||||
appName:
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_APP_NAME ?? DEFAULT_APP_NAME,
|
||||
}
|
||||
|
||||
if (!config.model.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (provider === 'openrouter' && !config.apiKey?.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (provider === 'openai-compatible' && !config.baseUrl.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function getRequiredLazyMonitoringJudgeConfig(): LazyMonitoringJudgeConfig {
|
||||
const config = resolveLazyMonitoringJudgeConfig()
|
||||
if (!config) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'lazy monitoring judge is not configured; set BROWSEROS_LAZY_MONITORING_JUDGE_MODEL and OPENROUTER_API_KEY or BROWSEROS_LAZY_MONITORING_JUDGE_API_KEY',
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function truncateString(value: string): string {
|
||||
if (value.length <= MAX_STRING_LENGTH) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, MAX_STRING_LENGTH)}... (+${value.length - MAX_STRING_LENGTH} chars)`
|
||||
}
|
||||
|
||||
function sanitizeForPrompt(value: unknown, depth = 0): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return truncateString(value)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.slice(0, MAX_ARRAY_ITEMS)
|
||||
.map((item) => sanitizeForPrompt(item, depth + 1))
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (depth >= 4) {
|
||||
return '[truncated]'
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.slice(0, MAX_OBJECT_KEYS)
|
||||
.map(([key, nested]) => [key, sanitizeForPrompt(nested, depth + 1)]),
|
||||
)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function extractMessageText(payload: unknown): string {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new LazyMonitoringJudgeError('judge response was not an object')
|
||||
}
|
||||
|
||||
const choices = (payload as { choices?: unknown }).choices
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'judge response did not include any choices',
|
||||
)
|
||||
}
|
||||
|
||||
const message = choices[0]
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new LazyMonitoringJudgeError('judge choice was malformed')
|
||||
}
|
||||
|
||||
const content = (message as { message?: { content?: unknown } }).message
|
||||
?.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content.trim()
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.flatMap((part) =>
|
||||
part && typeof part === 'object' && typeof part.text === 'string'
|
||||
? [part.text]
|
||||
: [],
|
||||
)
|
||||
.join('\n')
|
||||
.trim()
|
||||
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'judge response did not contain text content',
|
||||
)
|
||||
}
|
||||
|
||||
function extractJsonObject(text: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Fall through to brace extraction.
|
||||
}
|
||||
|
||||
const start = text.indexOf('{')
|
||||
const end = text.lastIndexOf('}')
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'judge response did not contain a JSON object',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(start, end + 1))
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
throw new LazyMonitoringJudgeError('judge response JSON was malformed')
|
||||
}
|
||||
|
||||
throw new LazyMonitoringJudgeError('judge response JSON must be an object')
|
||||
}
|
||||
|
||||
function normalizeDimensions(value: unknown): LazyMonitoringPolicyDimension[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = value.filter(
|
||||
(dimension): dimension is LazyMonitoringPolicyDimension =>
|
||||
typeof dimension === 'string' &&
|
||||
ALLOWED_DIMENSIONS.has(dimension as LazyMonitoringPolicyDimension),
|
||||
)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getPreviousUserPrompt(input: LazyMonitoringJudgeInput): string | null {
|
||||
for (let index = input.run.chatHistory.length - 1; index >= 0; index -= 1) {
|
||||
const turn = input.run.chatHistory[index]
|
||||
if (turn?.role === 'user' && typeof turn.content === 'string') {
|
||||
return turn.content
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const SNAPSHOT_ELEMENT_ARG_KEYS = [
|
||||
'element',
|
||||
'sourceElement',
|
||||
'targetElement',
|
||||
] as const
|
||||
const SNAPSHOT_LINE_PATTERN = /^\[(\d+)\]\s+/
|
||||
|
||||
function getTextContent(contentItem: unknown): string | null {
|
||||
if (!contentItem || typeof contentItem !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = contentItem as { type?: unknown; text?: unknown }
|
||||
|
||||
return record.type === 'text' && typeof record.text === 'string'
|
||||
? record.text
|
||||
: null
|
||||
}
|
||||
|
||||
function collectSnapshotLines(output: unknown): string[] {
|
||||
if (!output || typeof output !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
const record = output as {
|
||||
content?: unknown
|
||||
structuredContent?: { snapshot?: unknown }
|
||||
}
|
||||
|
||||
const snapshot = record.structuredContent?.snapshot
|
||||
if (typeof snapshot === 'string' && snapshot.trim()) {
|
||||
lines.push(...snapshot.split('\n'))
|
||||
}
|
||||
|
||||
if (Array.isArray(record.content)) {
|
||||
for (const item of record.content) {
|
||||
const text = getTextContent(item)
|
||||
if (text?.trim()) {
|
||||
lines.push(...text.split('\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => SNAPSHOT_LINE_PATTERN.test(line))
|
||||
}
|
||||
|
||||
function findLatestSnapshotLine(
|
||||
priorToolCalls: LazyMonitoringJudgeInput['priorToolCalls'],
|
||||
elementId: number,
|
||||
): {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
line: string
|
||||
} | null {
|
||||
for (
|
||||
let callIndex = priorToolCalls.length - 1;
|
||||
callIndex >= 0;
|
||||
callIndex -= 1
|
||||
) {
|
||||
const toolCall = priorToolCalls[callIndex]
|
||||
if (!toolCall) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lines = collectSnapshotLines(toolCall.output)
|
||||
for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex -= 1) {
|
||||
const line = lines[lineIndex]
|
||||
const match = line?.match(SNAPSHOT_LINE_PATTERN)
|
||||
if (match && Number(match[1]) === elementId) {
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
line,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function enrichCurrentToolArgsWithSnapshotContext(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): unknown {
|
||||
const args = input.currentToolCall.args
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
||||
return args
|
||||
}
|
||||
|
||||
const argRecord = args as Record<string, unknown>
|
||||
const lazyMonitoringContext: Record<string, unknown> = {}
|
||||
|
||||
for (const key of SNAPSHOT_ELEMENT_ARG_KEYS) {
|
||||
const elementId = argRecord[key]
|
||||
if (typeof elementId !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = findLatestSnapshotLine(input.priorToolCalls, elementId)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
lazyMonitoringContext[key] = {
|
||||
id: elementId,
|
||||
lastSnapshotLine: match.line,
|
||||
matchedFromToolCallId: match.toolCallId,
|
||||
matchedFromToolName: match.toolName,
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(lazyMonitoringContext).length === 0) {
|
||||
return args
|
||||
}
|
||||
|
||||
return {
|
||||
...argRecord,
|
||||
lazyMonitoringContext,
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolCallPayload(
|
||||
toolCall: MonitoringToolCallRecord,
|
||||
args = toolCall.args,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
toolDescription: toolCall.toolDescription,
|
||||
source: toolCall.source,
|
||||
args,
|
||||
output: toolCall.output,
|
||||
error: toolCall.error,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): Record<string, unknown> {
|
||||
const previousToolCall = input.priorToolCalls.at(-1)
|
||||
|
||||
return {
|
||||
currentUserPrompt: sanitizeForPrompt(input.run.originalPrompt),
|
||||
previousUserPrompt: sanitizeForPrompt(getPreviousUserPrompt(input)),
|
||||
previousToolCall: sanitizeForPrompt(
|
||||
previousToolCall ? buildToolCallPayload(previousToolCall) : null,
|
||||
),
|
||||
currentToolCall: sanitizeForPrompt(
|
||||
buildToolCallPayload(
|
||||
input.currentToolCall,
|
||||
enrichCurrentToolArgsWithSnapshotContext(input),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteLazyMonitoringJudgeClient {
|
||||
constructor(private readonly config: LazyMonitoringJudgeConfig) {}
|
||||
|
||||
async judge(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): Promise<LazyMonitoringJudgment> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
`judge request timed out after ${this.config.timeoutMs}ms`,
|
||||
),
|
||||
this.config.timeoutMs,
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.config.baseUrl.replace(/\/$/, '')}/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
temperature: 0,
|
||||
max_tokens: 160,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a passive judge for BrowserOS lazy monitoring. Review the proposed tool call in context using only the supplied evidence. Judge whether the tool call aligns with user intentions and isnt destructive. Do not be overly cautious and make inferences w.r.t the user prompt to make your judgement. Do not worry about any other aspect. Return strict JSON with keys verdict, summary, and policyDimensions. verdict must be one of safe, needs_review, suspicious, unsafe.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(buildPayload(input), null, 2),
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'lazy_monitoring_judgment',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
verdict: {
|
||||
type: 'string',
|
||||
enum: ['safe', 'needs_review', 'suspicious', 'unsafe'],
|
||||
},
|
||||
summary: { type: 'string' },
|
||||
policyDimensions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'scope_mismatch',
|
||||
'unexpected_side_effect',
|
||||
'destructive_action',
|
||||
'communication_risk',
|
||||
'data_access',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['verdict', 'summary', 'policyDimensions'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new LazyMonitoringJudgeError(
|
||||
`judge request failed with HTTP ${response.status}: ${detail}`,
|
||||
)
|
||||
}
|
||||
|
||||
const text = extractMessageText(await response.json())
|
||||
const verdict = extractJsonObject(text)
|
||||
const parsedVerdict = verdict.verdict
|
||||
const summary = verdict.summary
|
||||
const policyDimensions = normalizeDimensions(verdict.policyDimensions)
|
||||
|
||||
if (
|
||||
typeof parsedVerdict !== 'string' ||
|
||||
!ALLOWED_VERDICTS.has(parsedVerdict as LazyMonitoringVerdict)
|
||||
) {
|
||||
throw new LazyMonitoringJudgeError('judge verdict was invalid')
|
||||
}
|
||||
|
||||
if (typeof summary !== 'string' || !summary.trim()) {
|
||||
throw new LazyMonitoringJudgeError('judge summary was empty')
|
||||
}
|
||||
|
||||
return {
|
||||
monitoringSessionId: input.run.monitoringSessionId,
|
||||
agentId: input.run.agentId,
|
||||
toolCallId: input.currentToolCall.toolCallId,
|
||||
toolName: input.currentToolCall.toolName,
|
||||
verdict: parsedVerdict as LazyMonitoringVerdict,
|
||||
summary: summary.trim(),
|
||||
destructive: policyDimensions.includes('destructive_action'),
|
||||
shouldInterrupt:
|
||||
parsedVerdict === 'suspicious' || parsedVerdict === 'unsafe',
|
||||
mode: 'llm',
|
||||
categories: [],
|
||||
matchedIntentCategories: [],
|
||||
policyDimensions,
|
||||
policyVersion: 'lazy-monitoring-judge/v1',
|
||||
model: this.config.model,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LazyMonitoringJudgeError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const abortReason = controller.signal.reason
|
||||
const reasonDetail =
|
||||
typeof abortReason === 'string'
|
||||
? abortReason
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'judge request failed'
|
||||
|
||||
throw new LazyMonitoringJudgeError(reasonDetail, { cause: error })
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers.Authorization = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
if (this.config.provider === 'openrouter') {
|
||||
if (this.config.siteUrl) {
|
||||
headers['HTTP-Referer'] = this.config.siteUrl
|
||||
}
|
||||
headers['X-Title'] = this.config.appName ?? DEFAULT_APP_NAME
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
LazyMonitoringJudgeError,
|
||||
RemoteLazyMonitoringJudgeClient,
|
||||
resolveLazyMonitoringJudgeConfig,
|
||||
} from './llm-judge'
|
||||
import type { LazyMonitoringJudgeInput, LazyMonitoringJudgment } from './types'
|
||||
|
||||
export interface LazyMonitoringJudgeClient {
|
||||
judge(input: LazyMonitoringJudgeInput): Promise<LazyMonitoringJudgment>
|
||||
}
|
||||
|
||||
export class LazyMonitoringJudgeService {
|
||||
constructor(private readonly client?: LazyMonitoringJudgeClient) {}
|
||||
|
||||
async evaluate(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): Promise<LazyMonitoringJudgment> {
|
||||
if (!this.client) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'lazy monitoring judge is not configured',
|
||||
)
|
||||
}
|
||||
|
||||
return this.client.judge(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function createLazyMonitoringJudgeService(): LazyMonitoringJudgeService {
|
||||
const config = resolveLazyMonitoringJudgeConfig()
|
||||
return new LazyMonitoringJudgeService(
|
||||
config ? new RemoteLazyMonitoringJudgeClient(config) : undefined,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from '../types'
|
||||
|
||||
export type LazyMonitoringVerdict =
|
||||
| 'safe'
|
||||
| 'needs_review'
|
||||
| 'suspicious'
|
||||
| 'unsafe'
|
||||
|
||||
export type LazyMonitoringReviewMode = 'llm'
|
||||
|
||||
export type LazyMonitoringPolicyDimension =
|
||||
| 'scope_mismatch'
|
||||
| 'unexpected_side_effect'
|
||||
| 'destructive_action'
|
||||
| 'communication_risk'
|
||||
| 'data_access'
|
||||
|
||||
export interface LazyMonitoringJudgeInput {
|
||||
run: MonitoringSessionContext
|
||||
priorToolCalls: MonitoringToolCallRecord[]
|
||||
currentToolCall: MonitoringToolCallRecord
|
||||
}
|
||||
|
||||
export interface LazyMonitoringJudgment {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
verdict: LazyMonitoringVerdict
|
||||
summary: string
|
||||
destructive: boolean
|
||||
shouldInterrupt: boolean
|
||||
mode: LazyMonitoringReviewMode
|
||||
categories: string[]
|
||||
matchedIntentCategories: string[]
|
||||
policyDimensions: LazyMonitoringPolicyDimension[]
|
||||
policyVersion: string
|
||||
model?: string
|
||||
}
|
||||
@@ -16,3 +16,46 @@ export function swallowMonitoringError(
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildMonitoringToolOutput(output: {
|
||||
content?: unknown
|
||||
structuredContent?: unknown
|
||||
metadata?: unknown
|
||||
isError?: boolean
|
||||
}): Record<string, unknown> {
|
||||
const sanitizeContentItem = (item: unknown): unknown => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return item
|
||||
}
|
||||
|
||||
const record = item as {
|
||||
type?: unknown
|
||||
mimeType?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
if (
|
||||
record.type === 'image' &&
|
||||
typeof record.mimeType === 'string' &&
|
||||
typeof record.data === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: record.mimeType,
|
||||
omitted: true,
|
||||
dataLength: record.data.length,
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
content: Array.isArray(output.content)
|
||||
? output.content.map((item) => sanitizeContentItem(item))
|
||||
: output.content,
|
||||
structuredContent: output.structuredContent,
|
||||
metadata: output.metadata,
|
||||
isError: output.isError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { buildJudgeAuditEnvelope } from './envelope'
|
||||
import { LazyMonitoringJudgeError } from './judge/llm-judge'
|
||||
import type { LazyMonitoringJudgeService } from './judge/service'
|
||||
import { createLazyMonitoringJudgeService } from './judge/service'
|
||||
import { swallowMonitoringError, type ToolExecutionObserver } from './observer'
|
||||
import { MonitoringSessionRegistry } from './session-registry'
|
||||
import { MonitoringStorage } from './storage'
|
||||
@@ -19,9 +22,26 @@ type ActiveToolCallState = Omit<
|
||||
'finishedAt' | 'durationMs' | 'error' | 'output'
|
||||
>
|
||||
|
||||
interface MonitoringServiceDeps {
|
||||
storage?: MonitoringStorage
|
||||
registry?: MonitoringSessionRegistry
|
||||
judge?: LazyMonitoringJudgeService
|
||||
}
|
||||
|
||||
export class MonitoringService {
|
||||
private readonly storage = new MonitoringStorage()
|
||||
private readonly registry = new MonitoringSessionRegistry()
|
||||
private readonly storage: MonitoringStorage
|
||||
private readonly registry: MonitoringSessionRegistry
|
||||
private readonly judge: LazyMonitoringJudgeService
|
||||
private readonly completedToolCallsBySession = new Map<
|
||||
string,
|
||||
MonitoringToolCallRecord[]
|
||||
>()
|
||||
|
||||
constructor(deps: MonitoringServiceDeps = {}) {
|
||||
this.storage = deps.storage ?? new MonitoringStorage()
|
||||
this.registry = deps.registry ?? new MonitoringSessionRegistry()
|
||||
this.judge = deps.judge ?? createLazyMonitoringJudgeService()
|
||||
}
|
||||
|
||||
async startSession(
|
||||
input: MonitoringSessionStartInput,
|
||||
@@ -37,7 +57,12 @@ export class MonitoringService {
|
||||
}
|
||||
|
||||
await this.storage.writeContext(context)
|
||||
this.registry.setActive(context.agentId, context.monitoringSessionId)
|
||||
this.registry.setActive(
|
||||
context.agentId,
|
||||
context.monitoringSessionId,
|
||||
context.source,
|
||||
)
|
||||
this.completedToolCallsBySession.set(context.monitoringSessionId, [])
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -45,11 +70,19 @@ export class MonitoringService {
|
||||
return this.registry.getActive(agentId)
|
||||
}
|
||||
|
||||
getSingleActiveSession():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
return this.registry.getSingleActive()
|
||||
resolveSessionForMcpRequest(
|
||||
explicitAgentId?: string,
|
||||
): { agentId: string; monitoringSessionId: string } | undefined {
|
||||
if (explicitAgentId) {
|
||||
const monitoringSessionId = this.registry.getActive(explicitAgentId)
|
||||
return monitoringSessionId
|
||||
? { agentId: explicitAgentId, monitoringSessionId }
|
||||
: undefined
|
||||
}
|
||||
|
||||
return this.registry.resolveForUnattributedToolCalls()
|
||||
}
|
||||
|
||||
clearActiveSession(agentId: string, monitoringSessionId: string): void {
|
||||
this.registry.clearIfMatches(agentId, monitoringSessionId)
|
||||
}
|
||||
@@ -59,19 +92,106 @@ export class MonitoringService {
|
||||
agentId: string,
|
||||
): ToolExecutionObserver {
|
||||
const activeToolCalls = new Map<string, ActiveToolCallState>()
|
||||
const completedToolCalls =
|
||||
this.completedToolCallsBySession.get(monitoringSessionId) ?? []
|
||||
this.completedToolCallsBySession.set(
|
||||
monitoringSessionId,
|
||||
completedToolCalls,
|
||||
)
|
||||
const contextPromise = this.storage.readContext(monitoringSessionId)
|
||||
let judgeQueue = Promise.resolve()
|
||||
|
||||
const enqueueJudgeReview = (toolCall: ActiveToolCallState): void => {
|
||||
const priorToolCalls = [...completedToolCalls]
|
||||
|
||||
judgeQueue = judgeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const context = await contextPromise
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
|
||||
const judgment = await this.judge.evaluate({
|
||||
run: context,
|
||||
priorToolCalls,
|
||||
currentToolCall: toolCall,
|
||||
})
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: 'lazy-monitoring-judge',
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
originalPrompt: context.originalPrompt,
|
||||
toolCallId: judgment.toolCallId,
|
||||
toolName: judgment.toolName,
|
||||
verdict: judgment.verdict,
|
||||
summary: judgment.summary,
|
||||
mode: judgment.mode,
|
||||
destructive: judgment.destructive,
|
||||
categories: judgment.categories,
|
||||
matchedIntentCategories: judgment.matchedIntentCategories,
|
||||
policyDimensions: judgment.policyDimensions,
|
||||
policyVersion: judgment.policyVersion,
|
||||
model: judgment.model,
|
||||
shouldInterrupt: judgment.shouldInterrupt,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof LazyMonitoringJudgeError) {
|
||||
const errorPayload: Record<string, unknown> = {
|
||||
type: 'lazy-monitoring-judge-error',
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
if (error.cause) {
|
||||
const cause = error.cause
|
||||
errorPayload.cause =
|
||||
cause instanceof Error
|
||||
? {
|
||||
message: cause.message,
|
||||
name: cause.name,
|
||||
stack: cause.stack,
|
||||
}
|
||||
: String(cause)
|
||||
}
|
||||
console.error(JSON.stringify(errorPayload))
|
||||
this.storage
|
||||
.appendErrorLog(monitoringSessionId, errorPayload)
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
swallowMonitoringError('judge review', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
onToolStart: async (input: MonitoringToolStartInput) => {
|
||||
try {
|
||||
activeToolCalls.set(input.toolCallId, {
|
||||
const toolCall: ActiveToolCallState = {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
toolDescription: input.toolDescription,
|
||||
source: input.source,
|
||||
args: input.args,
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
activeToolCalls.set(input.toolCallId, toolCall)
|
||||
enqueueJudgeReview(toolCall)
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool start recording', error, {
|
||||
monitoringSessionId,
|
||||
@@ -108,6 +228,7 @@ export class MonitoringService {
|
||||
}
|
||||
|
||||
await this.storage.appendToolCall(record)
|
||||
completedToolCalls.push(record)
|
||||
activeToolCalls.delete(input.toolCallId)
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool end recording', error, {
|
||||
@@ -145,7 +266,11 @@ export class MonitoringService {
|
||||
|
||||
await this.storage.writeFinalization(finalization)
|
||||
this.registry.clearIfMatches(input.agentId, input.monitoringSessionId)
|
||||
return this.buildAndPersistEnvelope(input.monitoringSessionId)
|
||||
const envelope = await this.buildAndPersistEnvelope(
|
||||
input.monitoringSessionId,
|
||||
)
|
||||
this.completedToolCallsBySession.delete(input.monitoringSessionId)
|
||||
return envelope
|
||||
}
|
||||
|
||||
async getRunEnvelope(runId: string): Promise<JudgeAuditEnvelope | null> {
|
||||
|
||||
@@ -1,32 +1,66 @@
|
||||
export class MonitoringSessionRegistry {
|
||||
private readonly activeSessionsByAgent = new Map<string, string>()
|
||||
import type { MonitoringSessionContext } from './types'
|
||||
|
||||
setActive(agentId: string, monitoringSessionId: string): void {
|
||||
this.activeSessionsByAgent.set(agentId, monitoringSessionId)
|
||||
interface ActiveMonitoringSession {
|
||||
monitoringSessionId: string
|
||||
source: MonitoringSessionContext['source']
|
||||
}
|
||||
|
||||
export class MonitoringSessionRegistry {
|
||||
private readonly activeSessionsByAgent = new Map<
|
||||
string,
|
||||
ActiveMonitoringSession
|
||||
>()
|
||||
|
||||
setActive(
|
||||
agentId: string,
|
||||
monitoringSessionId: string,
|
||||
source: MonitoringSessionContext['source'],
|
||||
): void {
|
||||
this.activeSessionsByAgent.set(agentId, { monitoringSessionId, source })
|
||||
}
|
||||
|
||||
getActive(agentId: string): string | undefined {
|
||||
return this.activeSessionsByAgent.get(agentId)
|
||||
return this.activeSessionsByAgent.get(agentId)?.monitoringSessionId
|
||||
}
|
||||
|
||||
getSingleActive():
|
||||
resolveForUnattributedToolCalls():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
if (this.activeSessionsByAgent.size !== 1) {
|
||||
return undefined
|
||||
const activeSessions = [...this.activeSessionsByAgent.entries()].flatMap(
|
||||
([agentId, session]) =>
|
||||
session?.monitoringSessionId
|
||||
? [
|
||||
{
|
||||
agentId,
|
||||
monitoringSessionId: session.monitoringSessionId,
|
||||
source: session.source,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
if (activeSessions.length === 1) {
|
||||
const [{ agentId, monitoringSessionId }] = activeSessions
|
||||
return { agentId, monitoringSessionId }
|
||||
}
|
||||
|
||||
const [agentId, monitoringSessionId] =
|
||||
this.activeSessionsByAgent.entries().next().value ?? []
|
||||
const openClawSessions = activeSessions.filter(
|
||||
(session) => session.source === 'openclaw-agent-chat',
|
||||
)
|
||||
|
||||
if (!agentId || !monitoringSessionId) {
|
||||
return undefined
|
||||
if (openClawSessions.length === 1) {
|
||||
const [{ agentId, monitoringSessionId }] = openClawSessions
|
||||
return { agentId, monitoringSessionId }
|
||||
}
|
||||
|
||||
return { agentId, monitoringSessionId }
|
||||
return undefined
|
||||
}
|
||||
|
||||
clearIfMatches(agentId: string, monitoringSessionId: string): void {
|
||||
if (this.activeSessionsByAgent.get(agentId) !== monitoringSessionId) {
|
||||
if (
|
||||
this.activeSessionsByAgent.get(agentId)?.monitoringSessionId !==
|
||||
monitoringSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.activeSessionsByAgent.delete(agentId)
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
|
||||
const CONTEXT_FILE_NAME = 'context.json'
|
||||
const TOOL_CALLS_FILE_NAME = 'tool-calls.jsonl'
|
||||
const ERROR_LOG_FILE_NAME = 'error-log.jsonl'
|
||||
const FINALIZATION_FILE_NAME = 'finalization.json'
|
||||
const AUDIT_ENVELOPE_FILE_NAME = 'audit-envelope.json'
|
||||
const UUID_PATTERN =
|
||||
@@ -66,6 +67,17 @@ export class MonitoringStorage {
|
||||
)
|
||||
}
|
||||
|
||||
async appendErrorLog(
|
||||
runId: string,
|
||||
entry: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.ensureRunDir(runId)
|
||||
await appendFile(
|
||||
this.getErrorLogPath(runId),
|
||||
`${JSON.stringify({ ...entry, timestamp: new Date().toISOString() })}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeAuditEnvelope(runId: string, envelope: unknown): Promise<void> {
|
||||
await this.ensureRunDir(runId)
|
||||
await writeFile(
|
||||
@@ -168,6 +180,11 @@ export class MonitoringStorage {
|
||||
return join(getLazyMonitoringRunDir(runId), FINALIZATION_FILE_NAME)
|
||||
}
|
||||
|
||||
private getErrorLogPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), ERROR_LOG_FILE_NAME)
|
||||
}
|
||||
|
||||
private getAuditEnvelopePath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), AUDIT_ENVELOPE_FILE_NAME)
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface MonitoringToolCallRecord {
|
||||
agentId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
toolDescription?: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
output?: unknown
|
||||
@@ -72,6 +73,7 @@ export interface MonitoringSessionStartInput {
|
||||
export interface MonitoringToolStartInput {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
toolDescription?: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
}
|
||||
|
||||
@@ -91,8 +91,13 @@ export async function spawnBrowser(
|
||||
const browserProcess = spawn(
|
||||
config.binaryPath,
|
||||
[
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--use-mock-keychain',
|
||||
'--show-component-extension-options',
|
||||
// Match the supported dev/eval launch path and keep legacy BrowserOS
|
||||
// extensions from trying to talk to the removed controller bridge.
|
||||
'--disable-browseros-extensions',
|
||||
'--enable-logging=stderr',
|
||||
...(config.headless ? ['--headless=new'] : []),
|
||||
...config.extraArgs,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface FakeLimactlResponse {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export async function fakeLimactl(
|
||||
canned: Record<string, FakeLimactlResponse>,
|
||||
logPath?: string,
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-limactl-'))
|
||||
const path = join(dir, 'limactl')
|
||||
const limaHomeExpansion = '$' + '{LIMA_HOME-}'
|
||||
const cases = Object.entries(canned)
|
||||
.map(([command, response]) =>
|
||||
[
|
||||
` ${JSON.stringify(command)})`,
|
||||
` echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"`,
|
||||
` echo "LIMA_HOME:${limaHomeExpansion}" >> "${logPath ?? '/dev/null'}"`,
|
||||
` printf %b ${JSON.stringify(response.stdout ?? '')}`,
|
||||
` printf %b ${JSON.stringify(response.stderr ?? '')} >&2`,
|
||||
` exit ${response.exit ?? 0}`,
|
||||
' ;;',
|
||||
].join('\n'),
|
||||
)
|
||||
.join('\n')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
case "$1" in
|
||||
${cases}
|
||||
*)
|
||||
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
|
||||
echo "unexpected subcommand: $1" >&2
|
||||
exit 99
|
||||
;;
|
||||
esac
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface FakeSshResponse {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export async function fakeSsh(
|
||||
response: FakeSshResponse = {},
|
||||
logPath?: string,
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-ssh-'))
|
||||
const path = join(dir, 'ssh')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
|
||||
printf %b ${JSON.stringify(response.stdout ?? '')}
|
||||
printf %b ${JSON.stringify(response.stderr ?? '')} >&2
|
||||
exit ${response.exit ?? 0}
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path'
|
||||
const projectRoot = resolve(import.meta.dir, '..', '..')
|
||||
const testsRoot = resolve(projectRoot, 'tests')
|
||||
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
|
||||
const testPreloadPath = './tests/__helpers__/test-env.ts'
|
||||
const preferredDirectoryGroups = [
|
||||
'agent',
|
||||
'api',
|
||||
@@ -96,7 +97,7 @@ function runCommand(cmd: string[], label: string): number {
|
||||
console.log(`\n==> ${label}`)
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
env: withTestEnv(process.env),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
@@ -107,6 +108,30 @@ function runCommand(cmd: string[], label: string): number {
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
export function withTestEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (env.NODE_ENV) return env
|
||||
return { ...env, NODE_ENV: 'test' }
|
||||
}
|
||||
|
||||
export function buildTestCommand(
|
||||
targets: string[],
|
||||
junitPath?: string,
|
||||
): string[] {
|
||||
const cmd = [
|
||||
process.execPath,
|
||||
'--env-file=.env.development',
|
||||
'test',
|
||||
`--preload=${testPreloadPath}`,
|
||||
]
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
cmd.push(...targets)
|
||||
return cmd
|
||||
}
|
||||
|
||||
function runAtomicGroup(group: string): number {
|
||||
const targets = getAtomicGroupTargets(group)
|
||||
if (targets.length === 0) {
|
||||
@@ -116,13 +141,7 @@ function runAtomicGroup(group: string): number {
|
||||
}
|
||||
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
|
||||
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
|
||||
const cmd = [process.execPath, '--env-file=.env.development', 'test']
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
cmd.push(...targets)
|
||||
const cmd = buildTestCommand(targets, junitPath)
|
||||
return runCommand(cmd, `Running ${group} tests`)
|
||||
}
|
||||
|
||||
@@ -141,6 +160,7 @@ function runGroup(group: string): number {
|
||||
return runAtomicGroup(group)
|
||||
}
|
||||
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
|
||||
process.exit(runGroup(requestedGroup))
|
||||
if (import.meta.main) {
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
process.exit(runGroup(requestedGroup))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,49 @@ interface ServerState {
|
||||
|
||||
let serverState: ServerState | null = null
|
||||
|
||||
function appendBufferedLog(buffer: string[], chunk: Buffer | string): void {
|
||||
const text = chunk.toString()
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0)
|
||||
if (lines.length === 0) {
|
||||
return
|
||||
}
|
||||
buffer.push(...lines)
|
||||
const overflow = buffer.length - 40
|
||||
if (overflow > 0) {
|
||||
buffer.splice(0, overflow)
|
||||
}
|
||||
}
|
||||
|
||||
function formatStartupFailure(
|
||||
process: ChildProcess,
|
||||
port: number,
|
||||
stdoutBuffer: string[],
|
||||
stderrBuffer: string[],
|
||||
reason: string,
|
||||
): Error {
|
||||
const details: string[] = [reason]
|
||||
|
||||
if (process.exitCode !== null) {
|
||||
details.push(`exit code: ${process.exitCode}`)
|
||||
}
|
||||
if (process.signalCode) {
|
||||
details.push(`signal: ${process.signalCode}`)
|
||||
}
|
||||
|
||||
if (stderrBuffer.length > 0) {
|
||||
details.push(`stderr:\n${stderrBuffer.join('\n')}`)
|
||||
} else if (stdoutBuffer.length > 0) {
|
||||
details.push(`stdout:\n${stdoutBuffer.join('\n')}`)
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`Server failed to start on port ${port}. ${details.join('\n\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function isServerRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
@@ -37,14 +80,35 @@ export async function isServerRunning(port: number): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(port: number, maxAttempts = 30): Promise<void> {
|
||||
async function waitForHealth(
|
||||
process: ChildProcess,
|
||||
port: number,
|
||||
stdoutBuffer: string[],
|
||||
stderrBuffer: string[],
|
||||
maxAttempts = 60,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (await isServerRunning(port)) {
|
||||
return
|
||||
}
|
||||
if (process.exitCode !== null || process.signalCode) {
|
||||
throw formatStartupFailure(
|
||||
process,
|
||||
port,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
'Server process exited before /health became ready.',
|
||||
)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw new Error(`Server failed to start on port ${port} within timeout`)
|
||||
throw formatStartupFailure(
|
||||
process,
|
||||
port,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
'Timed out waiting for /health to become ready.',
|
||||
)
|
||||
}
|
||||
|
||||
export function getServerState(): ServerState | null {
|
||||
@@ -68,6 +132,8 @@ export async function spawnServer(config: ServerConfig): Promise<ServerState> {
|
||||
}
|
||||
|
||||
console.log(`Starting BrowserOS Server on port ${config.serverPort}...`)
|
||||
const stdoutBuffer: string[] = []
|
||||
const stderrBuffer: string[] = []
|
||||
const process = spawn(
|
||||
'bun',
|
||||
[
|
||||
@@ -87,14 +153,12 @@ export async function spawnServer(config: ServerConfig): Promise<ServerState> {
|
||||
},
|
||||
)
|
||||
|
||||
process.stdout?.on('data', (_data) => {
|
||||
// Uncomment for debugging
|
||||
// console.log(`[SERVER] ${_data.toString().trim()}`)
|
||||
process.stdout?.on('data', (data) => {
|
||||
appendBufferedLog(stdoutBuffer, data)
|
||||
})
|
||||
|
||||
process.stderr?.on('data', (_data) => {
|
||||
// Uncomment for debugging
|
||||
// console.error(`[SERVER] ${_data.toString().trim()}`)
|
||||
process.stderr?.on('data', (data) => {
|
||||
appendBufferedLog(stderrBuffer, data)
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
@@ -102,7 +166,7 @@ export async function spawnServer(config: ServerConfig): Promise<ServerState> {
|
||||
})
|
||||
|
||||
console.log('Waiting for server to be ready...')
|
||||
await waitForHealth(config.serverPort)
|
||||
await waitForHealth(process, config.serverPort, stdoutBuffer, stderrBuffer)
|
||||
console.log('Server is ready')
|
||||
|
||||
serverState = { process, config }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
if (!process.env.BROWSEROS_DIR) {
|
||||
process.env.BROWSEROS_DIR = mkdtempSync(
|
||||
join(tmpdir(), 'browseros-server-test-home-'),
|
||||
)
|
||||
}
|
||||
|
||||
const portBase = 36000 + (process.pid % 1000) * 20
|
||||
|
||||
if (!process.env.BROWSEROS_TEST_CDP_PORT) {
|
||||
process.env.BROWSEROS_TEST_CDP_PORT = String(portBase)
|
||||
}
|
||||
if (!process.env.BROWSEROS_TEST_SERVER_PORT) {
|
||||
process.env.BROWSEROS_TEST_SERVER_PORT = String(portBase + 1)
|
||||
}
|
||||
if (!process.env.BROWSEROS_TEST_EXTENSION_PORT) {
|
||||
process.env.BROWSEROS_TEST_EXTENSION_PORT = String(portBase + 2)
|
||||
}
|
||||
if (!process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT) {
|
||||
process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT = String(portBase + 3)
|
||||
}
|
||||
@@ -4,9 +4,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { OpenClawSessionNotFoundError } from '../../../src/api/services/openclaw/errors'
|
||||
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
|
||||
|
||||
describe('createOpenClawRoutes', () => {
|
||||
@@ -14,7 +12,7 @@ describe('createOpenClawRoutes', () => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('preserves BrowserOS SSE framing, session headers, and defaults chat history for chat', async () => {
|
||||
it('preserves BrowserOS SSE framing and normalizes recursive session keys for chat', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
@@ -53,7 +51,8 @@ describe('createOpenClawRoutes', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'hi',
|
||||
sessionKey: 'session-123',
|
||||
sessionKey:
|
||||
'agent:research:openai-user:browseros:research:agent:research:openai-user:browseros:research:session-123',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -264,18 +263,71 @@ describe('createOpenClawRoutes', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns the current podman overrides on GET', async () => {
|
||||
it('returns OpenClaw sessions for an agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getPodmanOverrides = mock(async () => ({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
effectivePodmanPath: '/opt/homebrew/bin/podman',
|
||||
const listSessions = mock(async () => [
|
||||
{
|
||||
key: 'openai-user:browseros:main:session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
])
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ listSessions }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/main/sessions?limit=1')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(listSessions).toHaveBeenCalledWith('main')
|
||||
expect(await response.json()).toEqual({
|
||||
agentId: 'main',
|
||||
sessions: [
|
||||
{
|
||||
key: 'openai-user:browseros:main:session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the resolved active OpenClaw session for an agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const resolveAgentSession = mock(async () => ({
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getPodmanOverrides }) as never,
|
||||
getOpenClawService: () => ({ resolveAgentSession }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
@@ -283,85 +335,61 @@ describe('createOpenClawRoutes', () => {
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides')
|
||||
const response = await route.request('/agents/main/session')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(resolveAgentSession).toHaveBeenCalledWith('main')
|
||||
expect(await response.json()).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
effectivePodmanPath: '/opt/homebrew/bin/podman',
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a relative podman path on POST', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: 'podman' }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'podmanPath must be an absolute path',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a nonexistent podman path on POST', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: '/does/not/exist/podman' }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'File does not exist: /does/not/exist/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-executable podman path on POST', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaw-route-'))
|
||||
const nonExec = join(tempDir, 'podman')
|
||||
writeFileSync(nonExec, 'not a binary')
|
||||
chmodSync(nonExec, 0o644)
|
||||
try {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: nonExec }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: `File is not executable: ${nonExec}`,
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('applies and echoes when POST clears the override', async () => {
|
||||
it('returns a normalized OpenClaw history page for an agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const applyPodmanOverrides = mock(async () => ({
|
||||
podmanPath: null,
|
||||
effectivePodmanPath: 'podman',
|
||||
const getAgentHistoryPage = mock(async () => ({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'Hello',
|
||||
timestamp: 1,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'session-1',
|
||||
source: 'other',
|
||||
},
|
||||
],
|
||||
page: {
|
||||
cursor: 'older-cursor',
|
||||
hasMore: true,
|
||||
limit: 25,
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ applyPodmanOverrides }) as never,
|
||||
getOpenClawService: () => ({ getAgentHistoryPage }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
@@ -369,16 +397,43 @@ describe('createOpenClawRoutes', () => {
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: null }),
|
||||
})
|
||||
const response = await route.request(
|
||||
'/agents/main/history?sessionKey=session-1&cursor=abc&limit=25',
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(applyPodmanOverrides).toHaveBeenCalledWith({ podmanPath: null })
|
||||
expect(getAgentHistoryPage).toHaveBeenCalledWith('main', {
|
||||
sessionKey: 'session-1',
|
||||
cursor: 'abc',
|
||||
limit: 25,
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
podmanPath: null,
|
||||
effectivePodmanPath: 'podman',
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'Hello',
|
||||
timestamp: 1,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'session-1',
|
||||
source: 'other',
|
||||
},
|
||||
],
|
||||
page: {
|
||||
cursor: 'older-cursor',
|
||||
hasMore: true,
|
||||
limit: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -434,4 +489,124 @@ describe('createOpenClawRoutes', () => {
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns JSON history from the session history route and forwards query params', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getSessionHistory = mock(async () => ({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request(
|
||||
'/session/agent%3Amain%3Amain/history?limit=25&cursor=next',
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('application/json')
|
||||
expect(getSessionHistory).toHaveBeenCalledWith('agent:main:main', {
|
||||
limit: 25,
|
||||
cursor: 'next',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when the service reports a missing session', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getSessionHistory = mock(async () => {
|
||||
throw new OpenClawSessionNotFoundError('missing')
|
||||
})
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/session/missing/history')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'OpenClaw session not found: missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('streams named SSE frames when Accept: text/event-stream', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const streamSessionHistory = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'history',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messages: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'message',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messageSeq: 2,
|
||||
message: { role: 'assistant', content: 'hi', messageSeq: 2 },
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ streamSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/session/k/history', {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(response.headers.get('X-Session-Key')).toBe('k')
|
||||
expect(streamSessionHistory).toHaveBeenCalledTimes(1)
|
||||
expect(streamSessionHistory.mock.calls[0]?.[0]).toBe('k')
|
||||
expect(await response.text()).toBe(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[],"cursor":null,"hasMore":false}\n\n' +
|
||||
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hi","messageSeq":2}}\n\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
serializeTerminalServerMessage,
|
||||
} from '../../../src/api/services/terminal/terminal-protocol'
|
||||
import {
|
||||
buildTerminalEnv,
|
||||
buildTerminalExecCommand,
|
||||
TERMINAL_HOME_DIR,
|
||||
} from '../../../src/api/services/terminal/terminal-session'
|
||||
@@ -50,15 +51,20 @@ describe('terminal protocol', () => {
|
||||
).toBe('{"type":"output","data":"hello"}')
|
||||
})
|
||||
|
||||
it('builds a podman exec command rooted in the container home dir', () => {
|
||||
it('builds a limactl shell command rooted in the container home dir', () => {
|
||||
expect(
|
||||
buildTerminalExecCommand(
|
||||
'podman',
|
||||
'limactl',
|
||||
'browseros-vm',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
),
|
||||
).toEqual([
|
||||
'podman',
|
||||
'limactl',
|
||||
'shell',
|
||||
'browseros-vm',
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
@@ -67,4 +73,13 @@ describe('terminal protocol', () => {
|
||||
'/bin/sh',
|
||||
])
|
||||
})
|
||||
|
||||
it('sets LIMA_HOME for terminal limactl sessions', () => {
|
||||
expect(buildTerminalEnv('/tmp/browseros-lima')).toEqual(
|
||||
expect.objectContaining({
|
||||
LIMA_HOME: '/tmp/browseros-lima',
|
||||
TERM: 'xterm-256color',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } 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.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('syncs the VM cache before deferred image loading reads the manifest', async () => {
|
||||
const ensureSynced = mock(async () => {
|
||||
throw new Error('cache sync sentinel')
|
||||
})
|
||||
const runtime = buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'darwin',
|
||||
vmCache: {
|
||||
ensureSynced,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow('cache sync sentinel')
|
||||
expect(ensureSynced).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
|
||||
|
||||
@@ -11,135 +11,85 @@ const PROJECT_DIR = '/tmp/openclaw'
|
||||
const defaultSpec = {
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
hostPort: 18789,
|
||||
hostHome: '/tmp/openclaw',
|
||||
envFilePath: '/tmp/openclaw/.openclaw/.env',
|
||||
hostHome: '/Users/me/.browseros/vm/openclaw',
|
||||
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
|
||||
gatewayToken: 'token-123',
|
||||
timezone: 'America/Los_Angeles',
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
runCommand: (
|
||||
args: string[],
|
||||
options?: { cwd?: string; onOutput?: (line: string) => void },
|
||||
) => Promise<number>,
|
||||
listRunningContainers: () => Promise<string[]> = async () => [],
|
||||
stopMachine: () => Promise<void> = async () => {},
|
||||
): ContainerRuntime {
|
||||
return new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand,
|
||||
tailContainerLogs: () => () => {},
|
||||
listRunningContainers,
|
||||
stopMachine,
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
}
|
||||
|
||||
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
spec.envFilePath,
|
||||
'-e',
|
||||
'HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${spec.timezone}`,
|
||||
'-v',
|
||||
`${spec.hostHome}:/home/node`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
'-e',
|
||||
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
|
||||
]
|
||||
}
|
||||
|
||||
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${spec.hostPort}:18789`,
|
||||
...expectedGatewayRuntimeArgs(spec),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
spec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
'18789',
|
||||
'--allow-unconfigured',
|
||||
]
|
||||
}
|
||||
|
||||
describe('ContainerRuntime', () => {
|
||||
it('pullImage runs podman pull for the requested image', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
|
||||
cwd: PROJECT_DIR,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('startGateway removes any existing gateway and runs a fresh container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
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(calls).toHaveLength(2)
|
||||
expect(calls[0]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
})
|
||||
expect(calls[1]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
})
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
|
||||
defaultSpec.image,
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: defaultSpec.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('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
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(
|
||||
@@ -147,180 +97,80 @@ describe('ContainerRuntime', () => {
|
||||
defaultSpec,
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'rm',
|
||||
'-f',
|
||||
'--ignore',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
...expectedGatewayRuntimeArgs(defaultSpec),
|
||||
defaultSpec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'agents',
|
||||
'list',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway removes the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.stopGateway()
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway is idempotent when the managed container is already absent', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.(
|
||||
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
|
||||
)
|
||||
return 0
|
||||
})
|
||||
|
||||
await expect(runtime.stopGateway()).resolves.toBeUndefined()
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('getGatewayLogs tails logs from the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.('first')
|
||||
options?.onOutput?.('second')
|
||||
return 0
|
||||
})
|
||||
|
||||
const logs = await runtime.getGatewayLogs(25)
|
||||
|
||||
expect(logs).toEqual(['first', 'second'])
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('restartGateway recreates and launches the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.restartGateway(defaultSpec)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe allows the managed gateway container', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
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',
|
||||
]),
|
||||
undefined,
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
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,
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('execInContainer targets the shared gateway container name', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
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,
|
||||
})
|
||||
|
||||
await runtime.execInContainer(['node', '--version'])
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: undefined,
|
||||
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('tailGatewayLogs targets the shared gateway container name', () => {
|
||||
const names: string[] = []
|
||||
const runtime = new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand: async () => 0,
|
||||
tailContainerLogs: (containerName: string) => {
|
||||
names.push(containerName)
|
||||
return () => {}
|
||||
},
|
||||
listRunningContainers: async () => [],
|
||||
stopMachine: async () => {},
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
|
||||
const stop = runtime.tailGatewayLogs(() => {})
|
||||
const logs = await runtime.getGatewayLogs(10)
|
||||
stop()
|
||||
|
||||
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
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 () => {}),
|
||||
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 () => {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,109 @@ describe('OpenClawCliClient', () => {
|
||||
await expect(client.listAgents()).rejects.toThrow('agent already exists')
|
||||
})
|
||||
|
||||
it('lists sessions for a specific agent', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
expect(command).toEqual([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'sessions',
|
||||
'--json',
|
||||
'--agent',
|
||||
'main',
|
||||
])
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
sessions: [
|
||||
{
|
||||
key: 'openai-user:browseros:main:session-1',
|
||||
updatedAt: 1710000000000,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
status: 'active',
|
||||
totalTokens: 120,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
modelProvider: 'openai',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
}),
|
||||
)
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const sessions = await client.listSessions('main')
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{
|
||||
key: 'openai-user:browseros:main:session-1',
|
||||
updatedAt: 1710000000000,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
status: 'active',
|
||||
totalTokens: 120,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
modelProvider: 'openai',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('fetches chat history through the OpenClaw gateway call command', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
expect(command).toEqual([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'call',
|
||||
'chat.history',
|
||||
'--params',
|
||||
'{"sessionKey":"session-1"}',
|
||||
'--json',
|
||||
])
|
||||
onLog?.(
|
||||
JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
timestamp: 1710000000001,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi there' }],
|
||||
timestamp: 1710000000002,
|
||||
usage: { input: 5, output: 6 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
return 0
|
||||
},
|
||||
)
|
||||
|
||||
const client = new OpenClawCliClient({ execInContainer })
|
||||
const history = await client.getChatHistory('session-1')
|
||||
|
||||
expect(history).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
timestamp: 1710000000001,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi there' }],
|
||||
timestamp: 1710000000002,
|
||||
usage: { input: 5, output: 6 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses config get output from mixed logs and pretty-printed JSON', async () => {
|
||||
const execInContainer = mock(
|
||||
async (command: string[], onLog?: (line: string) => void) => {
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawHttpChatClient } from '../../../../src/api/services/openclaw/openclaw-http-chat-client'
|
||||
|
||||
describe('OpenClawHttpChatClient', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('maps chat completion deltas into BrowserOS stream events', async () => {
|
||||
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
history: [{ role: 'assistant', content: 'Earlier reply' }],
|
||||
})
|
||||
|
||||
const events = await readEvents(stream)
|
||||
const call = fetchMock.mock.calls[0]
|
||||
|
||||
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
|
||||
expect(call?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
|
||||
model: 'openclaw/research',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Earlier reply' },
|
||||
{ role: 'user', content: 'hi' },
|
||||
],
|
||||
user: 'browseros:research:session-123',
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{ type: 'text-delta', data: { text: ' world' } },
|
||||
{ type: 'done', data: { text: 'Hello world' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses openclaw for the main agent', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
|
||||
model: string
|
||||
}
|
||||
expect(body.model).toBe('openclaw')
|
||||
})
|
||||
|
||||
it('throws on non-success HTTP responses', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{
|
||||
type: 'error',
|
||||
data: {
|
||||
message: "Agent couldn't generate a response. Please try again.",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
|
||||
'data: not-json\n\n' +
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpChatClient(
|
||||
18789,
|
||||
async () => 'gateway-token',
|
||||
)
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{
|
||||
type: 'error',
|
||||
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
|
||||
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
|
||||
const reader = stream.getReader()
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawSessionNotFoundError } from '../../../../src/api/services/openclaw/errors'
|
||||
import { OpenClawHttpClient } from '../../../../src/api/services/openclaw/openclaw-http-client'
|
||||
|
||||
describe('OpenClawHttpClient', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('maps chat completion deltas into BrowserOS stream events', async () => {
|
||||
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
history: [{ role: 'assistant', content: 'Earlier reply' }],
|
||||
})
|
||||
|
||||
const events = await readEvents(stream)
|
||||
const call = fetchMock.mock.calls[0]
|
||||
|
||||
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
|
||||
expect(call?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
|
||||
model: 'openclaw/research',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Earlier reply' },
|
||||
{ role: 'user', content: 'hi' },
|
||||
],
|
||||
user: 'browseros:research:session-123',
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{ type: 'text-delta', data: { text: ' world' } },
|
||||
{ type: 'done', data: { text: 'Hello world' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses openclaw for the main agent', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
|
||||
model: string
|
||||
}
|
||||
expect(body.model).toBe('openclaw')
|
||||
})
|
||||
|
||||
it('throws on non-success HTTP responses', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(
|
||||
client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('checks gateway authentication with the current bearer token', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(true)
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/v1/models',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats rejected gateway authentication as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('treats failed gateway authentication probes as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.reject(new Error('connect ECONNREFUSED')),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{
|
||||
type: 'error',
|
||||
data: {
|
||||
message: "Agent couldn't generate a response. Please try again.",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
|
||||
'data: not-json\n\n' +
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{
|
||||
type: 'error',
|
||||
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not double-close the stream controller when the request is aborted', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
},
|
||||
cancel() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
describe('getSessionHistory', () => {
|
||||
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', messageSeq: 1 },
|
||||
{ role: 'assistant', content: 'hello', messageSeq: 2 },
|
||||
],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const result = await client.getSessionHistory('agent:main:main', {
|
||||
limit: 50,
|
||||
cursor: 'abc',
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer gateway-token' },
|
||||
})
|
||||
expect(result).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', messageSeq: 1 },
|
||||
{ role: 'assistant', content: 'hello', messageSeq: 2 },
|
||||
],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits limit and cursor from the query when undefined', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/k/history',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws OpenClawSessionNotFoundError on 404', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(
|
||||
client.getSessionHistory('missing-key'),
|
||||
).rejects.toBeInstanceOf(OpenClawSessionNotFoundError)
|
||||
})
|
||||
|
||||
it('surfaces the response body on other non-2xx responses', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('boom', { status: 500 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
|
||||
})
|
||||
|
||||
it('propagates the abort signal to fetch', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const controller = new AbortController()
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await client.getSessionHistory('k', { signal: controller.signal })
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamSessionHistory', () => {
|
||||
it('parses named history/message SSE events into typed events', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[{"role":"user","content":"hi","messageSeq":1}],"cursor":null,"hasMore":false}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hey","messageSeq":2}}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamSessionHistory('k', { limit: 20 })
|
||||
|
||||
const events = await readEvents(stream)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/k/history?limit=20',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'history',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messageSeq: 2,
|
||||
message: { role: 'assistant', content: 'hey', messageSeq: 2 },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards upstream error frames and closes', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: error\ndata: {"message":"upstream exploded"}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamSessionHistory('k')
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{ type: 'error', data: { message: 'upstream exploded' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('throws OpenClawSessionNotFoundError on 404', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
|
||||
OpenClawSessionNotFoundError,
|
||||
)
|
||||
})
|
||||
|
||||
it('closes when the abort signal fires mid-stream', async () => {
|
||||
const ac = new AbortController()
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[]}\n\n',
|
||||
),
|
||||
)
|
||||
// Keep the stream open; abort should close it from our side.
|
||||
await new Promise((resolve) => {
|
||||
ac.signal.addEventListener(
|
||||
'abort',
|
||||
() => resolve(undefined),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamSessionHistory('k', {
|
||||
signal: ac.signal,
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
const first = await reader.read()
|
||||
expect(first.done).toBe(false)
|
||||
expect(first.value).toMatchObject({ type: 'history' })
|
||||
|
||||
ac.abort()
|
||||
const next = await reader.read()
|
||||
expect(next.done).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
|
||||
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
|
||||
const reader = stream.getReader()
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
@@ -14,7 +13,10 @@ import {
|
||||
resolveSupportedOpenClawProvider,
|
||||
UnsupportedOpenClawProviderError,
|
||||
} from '../../../../src/api/services/openclaw/openclaw-provider-map'
|
||||
import { OpenClawService } from '../../../../src/api/services/openclaw/openclaw-service'
|
||||
import {
|
||||
normalizeBrowserOSChatSessionKey,
|
||||
OpenClawService,
|
||||
} from '../../../../src/api/services/openclaw/openclaw-service'
|
||||
|
||||
type MutableOpenClawService = OpenClawService & {
|
||||
openclawDir: string
|
||||
@@ -41,15 +43,21 @@ type MutableOpenClawService = OpenClawService & {
|
||||
stopGateway?: (_onLog?: (_line: string) => void) => Promise<void>
|
||||
getGatewayLogs?: (_tail?: number) => Promise<string[]>
|
||||
waitForReady?: () => Promise<boolean>
|
||||
stopMachineIfSafe?: () => Promise<void>
|
||||
stopVm?: () => Promise<void>
|
||||
}
|
||||
cliClient: {
|
||||
probe?: ReturnType<typeof mock>
|
||||
createAgent?: ReturnType<typeof mock>
|
||||
getConfig?: ReturnType<typeof mock>
|
||||
getChatHistory?: ReturnType<typeof mock>
|
||||
listAgents?: ReturnType<typeof mock>
|
||||
listSessions?: ReturnType<typeof mock>
|
||||
setDefaultModel?: ReturnType<typeof mock>
|
||||
}
|
||||
httpClient: {
|
||||
streamChat?: ReturnType<typeof mock>
|
||||
getSessionHistory?: ReturnType<typeof mock>
|
||||
}
|
||||
bootstrapCliClient: {
|
||||
runOnboard?: ReturnType<typeof mock>
|
||||
setConfigBatch?: ReturnType<typeof mock>
|
||||
@@ -60,15 +68,25 @@ type MutableOpenClawService = OpenClawService & {
|
||||
|
||||
describe('OpenClawService', () => {
|
||||
let tempDir: string | null = null
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
}
|
||||
})
|
||||
|
||||
function getSyntheticOccupiedPort(): number {
|
||||
const forced = Number.parseInt(
|
||||
process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT ?? '41003',
|
||||
10,
|
||||
)
|
||||
return forced >= 65000 ? forced - 10 : forced + 10
|
||||
}
|
||||
|
||||
it('creates agents through the cli client without role bootstrap files', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const createAgent = mock(async () => ({
|
||||
@@ -147,6 +165,276 @@ describe('OpenClawService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves the latest user-chat session for an agent', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw', 'agents', 'main', 'sessions'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'agents', 'main', 'sessions', 'sessions.json'),
|
||||
JSON.stringify({
|
||||
'agent:main:cron:daily': {
|
||||
sessionId: 'cron-session',
|
||||
updatedAt: 30,
|
||||
},
|
||||
'openai-user:browseros:main:chat-session': {
|
||||
sessionId: 'chat-session',
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
expect(service.resolveAgentSession('main')).toEqual({
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'openai-user:browseros:main:chat-session',
|
||||
session: {
|
||||
key: 'openai-user:browseros:main:chat-session',
|
||||
updatedAt: 20,
|
||||
sessionId: 'chat-session',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes recursive OpenClaw BrowserOS session keys to the raw chat session id', () => {
|
||||
expect(
|
||||
normalizeBrowserOSChatSessionKey(
|
||||
'main',
|
||||
'agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
),
|
||||
).toBe('e1ee8e17-4fdb-4072-99ce-8f680853ec00')
|
||||
expect(
|
||||
normalizeBrowserOSChatSessionKey(
|
||||
'main',
|
||||
'agent:main:openai-user:browseros:main:agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
),
|
||||
).toBe('e1ee8e17-4fdb-4072-99ce-8f680853ec00')
|
||||
})
|
||||
|
||||
it('returns the raw BrowserOS session id while retaining the OpenClaw key for diagnostics', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw', 'agents', 'main', 'sessions'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'agents', 'main', 'sessions', 'sessions.json'),
|
||||
JSON.stringify({
|
||||
'agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00':
|
||||
{
|
||||
sessionId: 'chat-session',
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
expect(service.resolveAgentSession('main')).toEqual({
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
session: {
|
||||
key: 'agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
updatedAt: 20,
|
||||
sessionId: 'chat-session',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves recursive active sessions back to the canonical OpenClaw transcript key', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw', 'agents', 'main', 'sessions'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'agents', 'main', 'sessions', 'sessions.json'),
|
||||
JSON.stringify({
|
||||
'agent:main:openai-user:browseros:main:agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00':
|
||||
{
|
||||
sessionId: 'nested-session',
|
||||
updatedAt: 30,
|
||||
},
|
||||
'agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00':
|
||||
{
|
||||
sessionId: 'canonical-session',
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
expect(service.resolveAgentSession('main')).toEqual({
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
session: {
|
||||
key: 'agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
updatedAt: 20,
|
||||
sessionId: 'canonical-session',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the canonical OpenClaw key when history is requested with a recursive session key', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw', 'agents', 'main', 'sessions'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'agents', 'main', 'sessions', 'sessions.json'),
|
||||
JSON.stringify({
|
||||
'agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00':
|
||||
{
|
||||
sessionId: 'chat-session',
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
join(
|
||||
tempDir,
|
||||
'.openclaw',
|
||||
'agents',
|
||||
'main',
|
||||
'sessions',
|
||||
'chat-session.jsonl',
|
||||
),
|
||||
[
|
||||
'{"type":"message","id":"m1","timestamp":"1970-01-01T00:00:00.001Z","message":{"role":"user","content":[{"type":"text","text":"Old question"}]}}',
|
||||
'{"type":"message","id":"m2","timestamp":"1970-01-01T00:00:00.002Z","message":{"role":"assistant","content":[{"type":"text","text":"Old answer"}]}}',
|
||||
].join('\n'),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const page = service.getAgentHistoryPage('main', {
|
||||
sessionKey:
|
||||
'agent:main:openai-user:browseros:main:agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
})
|
||||
|
||||
expect(page.sessionKey).toBe('e1ee8e17-4fdb-4072-99ce-8f680853ec00')
|
||||
expect(page.items).toEqual([
|
||||
{
|
||||
id: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00:0',
|
||||
role: 'user',
|
||||
text: 'Old question',
|
||||
timestamp: 1,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
source: 'user-chat',
|
||||
},
|
||||
{
|
||||
id: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00:1',
|
||||
role: 'assistant',
|
||||
text: 'Old answer',
|
||||
timestamp: 2,
|
||||
messageSeq: 1,
|
||||
sessionKey: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
source: 'user-chat',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns normalized paginated chat history for an agent session', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw', 'agents', 'main', 'sessions'), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'agents', 'main', 'sessions', 'sessions.json'),
|
||||
JSON.stringify({
|
||||
'openai-user:browseros:main:chat-session': {
|
||||
sessionId: 'pi-session',
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
join(
|
||||
tempDir,
|
||||
'.openclaw',
|
||||
'agents',
|
||||
'main',
|
||||
'sessions',
|
||||
'pi-session.jsonl',
|
||||
),
|
||||
[
|
||||
'{"type":"message","id":"m0","timestamp":"1970-01-01T00:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"HEARTBEAT_OK"}]}}',
|
||||
'{"type":"message","id":"m1","timestamp":"1970-01-01T00:00:00.001Z","message":{"role":"user","content":[{"type":"text","text":"First question"}]}}',
|
||||
'{"type":"message","id":"m2","timestamp":"1970-01-01T00:00:00.002Z","message":{"role":"assistant","content":[{"type":"text","text":"First answer"}]}}',
|
||||
'{"type":"message","id":"m3","timestamp":"1970-01-01T00:00:00.003Z","message":{"role":"user","content":[{"type":"text","text":"[Chat messages since your last reply]\\n[Current message - respond to this]\\nUser: Second question"}]}}',
|
||||
].join('\n'),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const page = service.getAgentHistoryPage('main', { limit: 2 })
|
||||
|
||||
expect(page.agentId).toBe('main')
|
||||
expect(page.sessionKey).toBe('openai-user:browseros:main:chat-session')
|
||||
expect(page.items).toEqual([
|
||||
{
|
||||
id: 'openai-user:browseros:main:chat-session:1',
|
||||
role: 'assistant',
|
||||
text: 'First answer',
|
||||
timestamp: 2,
|
||||
messageSeq: 1,
|
||||
sessionKey: 'openai-user:browseros:main:chat-session',
|
||||
source: 'user-chat',
|
||||
},
|
||||
{
|
||||
id: 'openai-user:browseros:main:chat-session:2',
|
||||
role: 'user',
|
||||
text: 'Second question',
|
||||
timestamp: 3,
|
||||
messageSeq: 2,
|
||||
sessionKey: 'openai-user:browseros:main:chat-session',
|
||||
source: 'user-chat',
|
||||
},
|
||||
])
|
||||
expect(page.page.hasMore).toBe(true)
|
||||
expect(typeof page.page.cursor).toBe('string')
|
||||
})
|
||||
|
||||
it('normalizes recursive session keys before streaming chat', async () => {
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
const stream = new ReadableStream()
|
||||
const streamChat = mock(async () => stream)
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.httpClient = {
|
||||
streamChat,
|
||||
}
|
||||
|
||||
await expect(
|
||||
service.chatStream(
|
||||
'main',
|
||||
'agent:main:openai-user:browseros:main:agent:main:openai-user:browseros:main:e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
'hello',
|
||||
),
|
||||
).resolves.toBe(stream)
|
||||
expect(streamChat).toHaveBeenCalledWith({
|
||||
agentId: 'main',
|
||||
sessionKey: 'e1ee8e17-4fdb-4072-99ce-8f680853ec00',
|
||||
message: 'hello',
|
||||
history: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('maps successful cli client probes into connected status', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
@@ -212,9 +500,6 @@ describe('OpenClawService', () => {
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
const pullImage = mock(async () => {
|
||||
steps.push('pull')
|
||||
})
|
||||
const restartGateway = mock(async () => {
|
||||
steps.push('restart')
|
||||
})
|
||||
@@ -225,7 +510,6 @@ describe('OpenClawService', () => {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady: async () => {},
|
||||
isReady: async () => true,
|
||||
pullImage,
|
||||
restartGateway,
|
||||
startGateway,
|
||||
waitForReady: mock(async () => {
|
||||
@@ -279,18 +563,7 @@ describe('OpenClawService', () => {
|
||||
name: 'main',
|
||||
model: undefined,
|
||||
})
|
||||
expect(steps).toEqual([
|
||||
'pull',
|
||||
'onboard',
|
||||
'batch',
|
||||
'validate',
|
||||
'start',
|
||||
'ready',
|
||||
])
|
||||
expect(pullImage).toHaveBeenCalledWith(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
@@ -642,6 +915,7 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
const firstStart = service.start()
|
||||
await startGatewayEntered
|
||||
@@ -684,6 +958,7 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
await service.start()
|
||||
|
||||
@@ -706,6 +981,7 @@ describe('OpenClawService', () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const restartGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
@@ -713,6 +989,7 @@ describe('OpenClawService', () => {
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
@@ -720,9 +997,11 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
await service.restart()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
@@ -750,22 +1029,12 @@ describe('OpenClawService', () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const occupiedServer = createServer()
|
||||
const occupiedPort = await new Promise<number>((resolve, reject) => {
|
||||
occupiedServer.once('error', reject)
|
||||
occupiedServer.listen(0, '127.0.0.1', () => {
|
||||
const address = occupiedServer.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('failed to allocate test port'))
|
||||
return
|
||||
}
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
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 () => {})
|
||||
@@ -773,6 +1042,7 @@ describe('OpenClawService', () => {
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async (hostPort?: number) => hostPort === occupiedPort,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
@@ -780,20 +1050,9 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
try {
|
||||
await service.restart()
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
occupiedServer.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
await service.restart()
|
||||
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -801,6 +1060,57 @@ describe('OpenClawService', () => {
|
||||
}),
|
||||
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 () => {
|
||||
@@ -830,40 +1140,40 @@ describe('OpenClawService', () => {
|
||||
expect(getGatewayLogs).toHaveBeenCalledWith(25)
|
||||
})
|
||||
|
||||
it('shutdown stops gateway and then stops machine when safe', async () => {
|
||||
it('shutdown stops gateway and then stops the VM', async () => {
|
||||
const stopGateway = mock(async () => {})
|
||||
const stopMachineIfSafe = mock(async () => {})
|
||||
const stopVm = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
stopMachineIfSafe,
|
||||
stopVm,
|
||||
}
|
||||
|
||||
await service.shutdown()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
|
||||
expect(stopVm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shutdown still stops machine when stopGateway fails', async () => {
|
||||
it('shutdown still stops the VM when stopGateway fails', async () => {
|
||||
const stopGateway = mock(async () => {
|
||||
throw new Error('stop failed')
|
||||
})
|
||||
const stopMachineIfSafe = mock(async () => {})
|
||||
const stopVm = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
stopMachineIfSafe,
|
||||
stopVm,
|
||||
}
|
||||
|
||||
await expect(service.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
|
||||
expect(stopVm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => {
|
||||
@@ -1423,61 +1733,10 @@ describe('OpenClawService', () => {
|
||||
'OPENAI_API_KEY=sk-test\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides persists the override and refreshes the runtime', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const result = await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
|
||||
expect(result.podmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
expect(result.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
|
||||
)
|
||||
expect(persisted).toEqual({ podmanPath: '/opt/homebrew/bin/podman' })
|
||||
|
||||
const reloaded = await service.getPodmanOverrides()
|
||||
expect(reloaded.podmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
expect(reloaded.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides with null clears the override and falls back', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService({
|
||||
resourcesDir: tempDir,
|
||||
}) as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
const cleared = await service.applyPodmanOverrides({ podmanPath: null })
|
||||
|
||||
expect(cleared.podmanPath).toBeNull()
|
||||
// resourcesDir has no bundled binary, so the runtime falls through to 'podman'
|
||||
expect(cleared.effectivePodmanPath).toBe('podman')
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
|
||||
)
|
||||
expect(persisted).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides rebuilds ContainerRuntime so it picks up the new Podman reference', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const before = service.runtime
|
||||
await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
|
||||
expect(service.runtime).not.toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('', { status })))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
getPodmanOverridesPath,
|
||||
loadPodmanOverrides,
|
||||
savePodmanOverrides,
|
||||
} from '../../../../src/api/services/openclaw/podman-overrides'
|
||||
|
||||
describe('podman overrides', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-ovr-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null podmanPath when the overrides file is missing', async () => {
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('round-trips save and load', async () => {
|
||||
await savePodmanOverrides(tempDir, {
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the overrides file is malformed JSON', async () => {
|
||||
fs.writeFileSync(getPodmanOverridesPath(tempDir), '{not json')
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('treats empty string and wrong types as null', async () => {
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: '' }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: 42 }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('persists an explicit null', async () => {
|
||||
await savePodmanOverrides(tempDir, { podmanPath: null })
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(tempDir))).toBe(true)
|
||||
})
|
||||
|
||||
it('creates the openclaw directory if it does not exist', async () => {
|
||||
const nested = path.join(tempDir, 'does-not-exist')
|
||||
await savePodmanOverrides(nested, { podmanPath: '/usr/local/bin/podman' })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(nested))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
configurePodmanRuntime,
|
||||
getPodmanRuntime,
|
||||
PodmanRuntime,
|
||||
resolveBundledPodmanPath,
|
||||
} from '../../../../src/api/services/openclaw/podman-runtime'
|
||||
|
||||
class FakePodmanRuntime extends PodmanRuntime {
|
||||
machineStatuses: Array<{ initialized: boolean; running: boolean }>
|
||||
initCalls = 0
|
||||
startCalls = 0
|
||||
statusCalls = 0
|
||||
|
||||
constructor(statuses: Array<{ initialized: boolean; running: boolean }>) {
|
||||
super({ podmanPath: 'podman' })
|
||||
this.machineStatuses = [...statuses]
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
this.statusCalls += 1
|
||||
return (
|
||||
this.machineStatuses.shift() ?? {
|
||||
initialized: true,
|
||||
running: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async initMachine(): Promise<void> {
|
||||
this.initCalls += 1
|
||||
}
|
||||
|
||||
async startMachine(): Promise<void> {
|
||||
this.startCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('podman runtime', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
configurePodmanRuntime({ podmanPath: 'podman' })
|
||||
})
|
||||
|
||||
it('returns the bundled podman path when the executable exists', () => {
|
||||
const bundledPath = path.join(
|
||||
tempDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'podman',
|
||||
'podman',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
|
||||
fs.writeFileSync(bundledPath, 'podman')
|
||||
|
||||
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBe(bundledPath)
|
||||
})
|
||||
|
||||
it('uses the windows executable name for bundled podman', () => {
|
||||
const bundledPath = path.join(
|
||||
tempDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'podman',
|
||||
'podman.exe',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
|
||||
fs.writeFileSync(bundledPath, 'podman')
|
||||
|
||||
expect(resolveBundledPodmanPath(tempDir, 'win32')).toBe(bundledPath)
|
||||
})
|
||||
|
||||
it('returns null when no bundled podman executable exists', () => {
|
||||
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBeNull()
|
||||
})
|
||||
|
||||
it('configures the runtime to prefer the bundled podman path', () => {
|
||||
const bundledPath = path.join(
|
||||
tempDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'podman',
|
||||
'podman',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
|
||||
fs.writeFileSync(bundledPath, 'podman')
|
||||
|
||||
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe(bundledPath)
|
||||
expect(getPodmanRuntime().getPodmanPath()).toBe(bundledPath)
|
||||
})
|
||||
|
||||
it('falls back to PATH podman when no bundled executable is present', () => {
|
||||
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe('podman')
|
||||
})
|
||||
|
||||
it('ensureReady re-checks machine status on every call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(3)
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('ensureReady initializes when machine is not present', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: false, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(1)
|
||||
expect(runtime.initCalls).toBe(1)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady starts when machine is initialized but stopped', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady detects an externally stopped machine on the next call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(2)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -18,9 +18,11 @@ import { logger } from '../src/lib/logger'
|
||||
|
||||
describe('getBrowserosDir', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalBrowserosDir = process.env.BROWSEROS_DIR
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.NODE_ENV
|
||||
delete process.env.BROWSEROS_DIR
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -30,6 +32,13 @@ describe('getBrowserosDir', () => {
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
|
||||
if (originalBrowserosDir === undefined) {
|
||||
delete process.env.BROWSEROS_DIR
|
||||
return
|
||||
}
|
||||
|
||||
process.env.BROWSEROS_DIR = originalBrowserosDir
|
||||
})
|
||||
|
||||
it('uses a separate home directory in development', () => {
|
||||
|
||||
@@ -34,6 +34,8 @@ const REQUIRED_INLINE_ENV_KEYS = [
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'SENTRY_DSN',
|
||||
'BROWSEROS_VM_CACHE_PREFETCH',
|
||||
'BROWSEROS_VM_CACHE_MANIFEST_URL',
|
||||
] as const
|
||||
|
||||
const R2_ENV_KEYS = [
|
||||
@@ -50,6 +52,8 @@ const INLINE_ENV_STUBS: Record<string, string> = {
|
||||
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
|
||||
POSTHOG_API_KEY: 'phc_test_stub',
|
||||
SENTRY_DSN: 'https://stub@sentry.test/0',
|
||||
BROWSEROS_VM_CACHE_PREFETCH: 'true',
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL: 'https://stub.test/vm/manifest.json',
|
||||
}
|
||||
|
||||
const R2_ENV_STUBS: Record<string, string> = {
|
||||
|
||||
@@ -28,6 +28,8 @@ describe('loadServerConfig', () => {
|
||||
delete process.env.BROWSEROS_INSTALL_ID
|
||||
delete process.env.BROWSEROS_CLIENT_ID
|
||||
delete process.env.BROWSEROS_AI_SDK_DEVTOOLS
|
||||
delete process.env.BROWSEROS_VM_CACHE_PREFETCH
|
||||
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -444,6 +446,75 @@ describe('loadServerConfig', () => {
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.aiSdkDevtoolsEnabled, false)
|
||||
})
|
||||
|
||||
it('defaults VM cache runtime sync settings', () => {
|
||||
const result = loadServerConfig([
|
||||
'bun',
|
||||
'src/index.ts',
|
||||
'--server-port=3000',
|
||||
])
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.vmCachePrefetch, true)
|
||||
assert.strictEqual(
|
||||
result.value.vmCacheManifestUrl,
|
||||
'https://cdn.browseros.com/vm/manifest.json',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VM cache runtime sync', () => {
|
||||
it('reads VM cache settings from env', () => {
|
||||
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
|
||||
' https://manifest.test/vm.json '
|
||||
|
||||
const result = loadServerConfig([
|
||||
'bun',
|
||||
'src/index.ts',
|
||||
'--server-port=3000',
|
||||
])
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.vmCachePrefetch, false)
|
||||
assert.strictEqual(
|
||||
result.value.vmCacheManifestUrl,
|
||||
'https://manifest.test/vm.json',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads VM cache settings from config with file precedence over env', () => {
|
||||
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
|
||||
'https://env.test/manifest.json'
|
||||
const configPath = path.join(tempDir, 'config.json')
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
ports: { server: 3000 },
|
||||
vm_cache: {
|
||||
prefetch: true,
|
||||
manifest_url: ' https://config.test/vm/manifest.json ',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = loadServerConfig([
|
||||
'bun',
|
||||
'src/index.ts',
|
||||
`--config=${configPath}`,
|
||||
])
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.strictEqual(result.value.vmCachePrefetch, true)
|
||||
assert.strictEqual(
|
||||
result.value.vmCacheManifestUrl,
|
||||
'https://config.test/vm/manifest.json',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI SDK DevTools', () => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { ContainerCli } from '../../src/lib/container'
|
||||
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
VM_NAME,
|
||||
} from '../../src/lib/vm/paths'
|
||||
|
||||
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
|
||||
const limactlPath = process.env.LIMACTL_PATH ?? 'limactl'
|
||||
const templatePath = resolve(
|
||||
import.meta.dir,
|
||||
'../../../../packages/build-tools/template/browseros-vm.yaml',
|
||||
)
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {},
|
||||
}
|
||||
|
||||
describe('BrowserOS VM live smoke', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/bovm-')
|
||||
limaHome = join(root, 'lima')
|
||||
const manifestPath = getCachedManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (process.env.LIVE_VM_SMOKE === '1') {
|
||||
await new LimaCli({ limactlPath, limaHome })
|
||||
.delete(VM_NAME)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
liveIt(
|
||||
'creates, starts, uses, stops, and deletes the BrowserOS Lima VM',
|
||||
async () => {
|
||||
expect(existsSync(templatePath)).toBe(true)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
readinessTimeoutMs: 5 * 60 * 1000,
|
||||
readinessPollMs: 1000,
|
||||
})
|
||||
const cli = new ContainerCli({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
vmName: VM_NAME,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
expect((await stat(getContainerdSocketPath(root))).isSocket()).toBe(true)
|
||||
const nerdctlInfoOutput: string[] = []
|
||||
const nerdctlInfo = await cli.runCommand(['info'], (line) =>
|
||||
nerdctlInfoOutput.push(line),
|
||||
)
|
||||
if (nerdctlInfo.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`nerdctl info failed with exit ${nerdctlInfo.exitCode}:\n${nerdctlInfoOutput.join('\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
await cli.pullImage('docker.io/library/hello-world:latest')
|
||||
|
||||
const secondStart = Date.now()
|
||||
await runtime.ensureReady()
|
||||
expect(Date.now() - secondStart).toBeLessThan(10_000)
|
||||
|
||||
await runtime.stopVm()
|
||||
const vm = (await new LimaCli({ limactlPath, limaHome }).list()).find(
|
||||
(entry) => entry.name === VM_NAME,
|
||||
)
|
||||
expect(vm?.status).toBe('Stopped')
|
||||
},
|
||||
LIVE_VM_SMOKE_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ContainerCliError } from '../../../src/lib/vm/errors'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
describe('ContainerCli', () => {
|
||||
let tempDir: string
|
||||
let logPath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp('/tmp/container-cli-')
|
||||
logPath = join(tempDir, 'ssh.log')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('checks image existence with nerdctl image inspect', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(true)
|
||||
|
||||
const sshConfig = sshConfigPath(tempDir)
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfig)} 'nerdctl' 'image' 'inspect' 'openclaw:v1'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when image inspect exits non-zero', async () => {
|
||||
const sshPath = await fakeSsh({ stderr: 'missing', exit: 1 }, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('pulls images with progress and throws typed command errors', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
const lines: string[] = []
|
||||
|
||||
const error = await cli
|
||||
.pullImage('openclaw:v1', (line) => lines.push(line))
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ContainerCliError)
|
||||
expect(error.exitCode).toBe(2)
|
||||
expect(error.stderr).toBe('denied')
|
||||
expect(lines).toContain('pulling')
|
||||
expect(lines).toContain('denied')
|
||||
})
|
||||
|
||||
it('loads images from guest tarballs and returns loaded refs', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'Loaded image(s): openclaw:v1\n' },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(
|
||||
cli.loadImage('/mnt/browseros/cache/images/openclaw.tar.gz'),
|
||||
).resolves.toEqual(['openclaw:v1'])
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'load' '-i' '/mnt/browseros/cache/images/openclaw.tar.gz'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('creates containers from typed specs', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await cli.createContainer({
|
||||
name: 'gateway',
|
||||
image: 'openclaw:v1',
|
||||
restart: 'unless-stopped',
|
||||
ports: [{ hostIp: '127.0.0.1', hostPort: 18789, containerPort: 18789 }],
|
||||
envFile: '/mnt/browseros/vm/openclaw/.env',
|
||||
env: { HOME: '/home/node', NODE_ENV: 'production' },
|
||||
mounts: [
|
||||
{
|
||||
source: '/mnt/browseros/vm/openclaw',
|
||||
target: '/home/node',
|
||||
readonly: true,
|
||||
},
|
||||
],
|
||||
addHosts: ['host.containers.internal:192.168.5.2'],
|
||||
health: {
|
||||
cmd: 'curl -sf http://127.0.0.1:18789/healthz',
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
},
|
||||
command: ['node', 'dist/index.js', 'gateway'],
|
||||
})
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
[
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'create'`,
|
||||
"'--name' 'gateway'",
|
||||
"'--restart' 'unless-stopped'",
|
||||
"'-p' '127.0.0.1:18789:18789'",
|
||||
"'--env-file' '/mnt/browseros/vm/openclaw/.env'",
|
||||
"'-e' 'HOME=/home/node'",
|
||||
"'-e' 'NODE_ENV=production'",
|
||||
"'-v' '/mnt/browseros/vm/openclaw:/home/node:ro'",
|
||||
"'--add-host' 'host.containers.internal:192.168.5.2'",
|
||||
"'--health-cmd' 'curl -sf http://127.0.0.1:18789/healthz'",
|
||||
"'--health-interval' '30s'",
|
||||
"'--health-timeout' '10s'",
|
||||
"'--health-retries' '3'",
|
||||
"'openclaw:v1' 'node' 'dist/index.js' 'gateway'",
|
||||
].join(' '),
|
||||
)
|
||||
})
|
||||
|
||||
it('starts, stops, removes, execs, and lists containers', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\n' }, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await cli.startContainer('gateway')
|
||||
await cli.stopContainer('gateway')
|
||||
await cli.removeContainer('gateway', { force: true })
|
||||
await expect(cli.exec('gateway', ['node', '--version'])).resolves.toBe(0)
|
||||
await expect(cli.ps({ namesOnly: true })).resolves.toEqual([
|
||||
'gateway',
|
||||
'worker',
|
||||
])
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain("lima-browseros-vm 'nerdctl' 'start' 'gateway'")
|
||||
expect(log).toContain("lima-browseros-vm 'nerdctl' 'stop' 'gateway'")
|
||||
expect(log).toContain("lima-browseros-vm 'nerdctl' 'rm' '-f' 'gateway'")
|
||||
expect(log).toContain(
|
||||
"lima-browseros-vm 'nerdctl' 'exec' 'gateway' 'node' '--version'",
|
||||
)
|
||||
expect(log).toContain(
|
||||
"lima-browseros-vm 'nerdctl' 'ps' '--format' '{{.Names}}'",
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates removal when the container is already absent', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stderr: 'no such container', exit: 1 },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.removeContainer('gateway', { force: true })).resolves.toBe(
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('tails logs and returns a stop handle', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'line\n' }, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
const lines: string[] = []
|
||||
|
||||
const stop = cli.tailLogs('gateway', (line) => lines.push(line))
|
||||
for (let attempts = 0; attempts < 50 && lines.length === 0; attempts += 1) {
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
stop()
|
||||
|
||||
expect(lines).toEqual(['line'])
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function createCli(
|
||||
sshPath: string,
|
||||
tempDir: string,
|
||||
): Promise<ContainerCli> {
|
||||
const configPath = sshConfigPath(tempDir)
|
||||
await mkdir(join(tempDir, 'lima', 'browseros-vm'), { recursive: true })
|
||||
await writeFile(configPath, '')
|
||||
return new ContainerCli({
|
||||
limactlPath: 'unused',
|
||||
limaHome: join(tempDir, 'lima'),
|
||||
sshPath,
|
||||
vmName: 'browseros-vm',
|
||||
})
|
||||
}
|
||||
|
||||
function sshConfigPath(tempDir: string): string {
|
||||
return join(tempDir, 'lima', 'browseros-vm', 'ssh.config')
|
||||
}
|
||||
|
||||
function sshPrefix(configPath: string): string {
|
||||
return `ARGS:-F ${configPath} lima-browseros-vm`
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
import type { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ImageLoader } from '../../../src/lib/container/image-loader'
|
||||
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import * as paths from '../../../src/lib/vm/paths'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('ImageLoader', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('returns without loading when the image already exists', async () => {
|
||||
const cli = new FakeContainerCli([true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('loads a missing image from the guest cache and verifies it exists', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(cli.loadCalls).toEqual([
|
||||
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
])
|
||||
expect(cli.existsCalls).toEqual([
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves image tarballs against the configured BrowserOS root', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const browserosRoot = '/tmp/browseros-custom-root'
|
||||
const loader = new ImageLoader(
|
||||
cli as never,
|
||||
manifest,
|
||||
'arm64',
|
||||
browserosRoot,
|
||||
)
|
||||
const getImageCacheDir = spyOn(paths, 'getImageCacheDir')
|
||||
const hostPathToGuest = spyOn(paths, 'hostPathToGuest')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(getImageCacheDir).toHaveBeenCalledWith(browserosRoot)
|
||||
expect(hostPathToGuest).toHaveBeenCalledWith(
|
||||
'/tmp/browseros-custom-root/cache/vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
browserosRoot,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws ImageLoadError when a loaded image is still absent', async () => {
|
||||
const cli = new FakeContainerCli([false, false])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(
|
||||
loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow(ImageLoadError)
|
||||
})
|
||||
|
||||
it('throws ImageLoadError for unknown refs without loading', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(loader.ensureImageLoaded('missing:v1')).rejects.toThrow(
|
||||
ImageLoadError,
|
||||
)
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
const error = await loader
|
||||
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.cause).toBe(cli.loadError)
|
||||
})
|
||||
})
|
||||
|
||||
class FakeContainerCli
|
||||
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
|
||||
{
|
||||
existsCalls: string[] = []
|
||||
loadCalls: string[] = []
|
||||
loadError: Error | null = null
|
||||
|
||||
constructor(private readonly existsResponses: boolean[]) {}
|
||||
|
||||
async imageExists(ref: string): Promise<boolean> {
|
||||
this.existsCalls.push(ref)
|
||||
return this.existsResponses.shift() ?? false
|
||||
}
|
||||
|
||||
async loadImage(path: string): Promise<string[]> {
|
||||
this.loadCalls.push(path)
|
||||
if (this.loadError) throw this.loadError
|
||||
return ['loaded']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
ensureVmCacheAvailable,
|
||||
ensureVmCacheSynced,
|
||||
prefetchVmCache,
|
||||
} from '../../../src/lib/vm/cache-sync'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import { getCachedManifestPath } from '../../../src/lib/vm/paths'
|
||||
|
||||
const CDN_BASE = 'https://cdn.test'
|
||||
const MANIFEST_URL = `${CDN_BASE}/vm/manifest.json`
|
||||
const TARBALL_KEY = 'vm/images/openclaw-2026.4.12-arm64.tar.gz'
|
||||
const TARBALL_BYTES = new TextEncoder().encode('openclaw-tarball')
|
||||
const TARBALL_SHA = sha256(TARBALL_BYTES)
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: TARBALL_KEY,
|
||||
sha256: TARBALL_SHA,
|
||||
sizeBytes: TARBALL_BYTES.byteLength,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'unused',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('runtime VM cache sync', () => {
|
||||
let root: string
|
||||
let originalManifestUrl: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/browseros-vm-cache-sync-')
|
||||
originalManifestUrl = process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
|
||||
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv('BROWSEROS_VM_CACHE_MANIFEST_URL', originalManifestUrl)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('downloads the host-arch tarball, verifies it, and writes the manifest last', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl = fakeVmCacheFetch(calls)
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
|
||||
expect(result).toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
expect(
|
||||
JSON.parse(await readFile(getCachedManifestPath(root), 'utf8')),
|
||||
).toEqual(manifest)
|
||||
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
|
||||
'openclaw-tarball',
|
||||
)
|
||||
await expect(
|
||||
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('uses the runtime env manifest URL and resolves artifacts beside it', async () => {
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
|
||||
'https://artifacts.test/vm/manifest.json'
|
||||
const calls: string[] = []
|
||||
const fetchImpl = fakeVmCacheFetch(calls, {
|
||||
manifestUrl: 'https://artifacts.test/vm/manifest.json',
|
||||
tarballUrl: `https://artifacts.test/${TARBALL_KEY}`,
|
||||
})
|
||||
|
||||
await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
'https://artifacts.test/vm/manifest.json',
|
||||
`https://artifacts.test/${TARBALL_KEY}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('skips downloads when the matching manifest and tarball already exist', async () => {
|
||||
await writeLocalManifest(root)
|
||||
await writeLocalTarball(root)
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL])
|
||||
expect(result.downloaded).toEqual([])
|
||||
expect(result.skipped).toBe(true)
|
||||
})
|
||||
|
||||
it('downloads a tarball when the manifest matches but the file is missing', async () => {
|
||||
await writeLocalManifest(root)
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
|
||||
expect(result.downloaded).toEqual([TARBALL_KEY])
|
||||
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
|
||||
'openclaw-tarball',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an existing tarball when the local manifest is missing but the hash matches', async () => {
|
||||
await writeLocalTarball(root)
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(calls).toEqual([MANIFEST_URL])
|
||||
expect(result.downloaded).toEqual([])
|
||||
expect(result.skipped).toBe(true)
|
||||
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it('shares concurrent prefetch calls through one in-flight sync', async () => {
|
||||
const calls: string[] = []
|
||||
let resolveManifest: (response: Response) => void = () => {}
|
||||
const manifestResponse = new Promise<Response>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (url === MANIFEST_URL) return manifestResponse
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const first = prefetchVmCache({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
const second = prefetchVmCache({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(calls).toEqual([MANIFEST_URL])
|
||||
|
||||
resolveManifest(jsonResponse(manifest))
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
await expect(second).resolves.toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
|
||||
})
|
||||
|
||||
it('syncs different roots independently while another sync is in flight', async () => {
|
||||
const otherRoot = await mkdtemp('/tmp/browseros-vm-cache-sync-other-')
|
||||
try {
|
||||
const calls: string[] = []
|
||||
let resolveManifest: (response: Response) => void = () => {}
|
||||
const manifestResponse = new Promise<Response>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const first = prefetchVmCache({
|
||||
browserosRoot: otherRoot,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
const second = ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
expect(second).not.toBe(first)
|
||||
await second
|
||||
|
||||
resolveManifest(jsonResponse(manifest))
|
||||
await first
|
||||
|
||||
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
await expect(
|
||||
readFile(getCachedManifestPath(otherRoot), 'utf8'),
|
||||
).resolves.toBe(`${JSON.stringify(manifest, null, 2)}\n`)
|
||||
expect(calls).toEqual([
|
||||
MANIFEST_URL,
|
||||
MANIFEST_URL,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
])
|
||||
} finally {
|
||||
await rm(otherRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries on-demand availability after an in-flight prefetch fails', async () => {
|
||||
const calls: string[] = []
|
||||
let resolveManifest: (response: Response) => void = () => {}
|
||||
const manifestResponse = new Promise<Response>((resolve) => {
|
||||
resolveManifest = resolve
|
||||
})
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const first = prefetchVmCache({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}).catch((error) => error)
|
||||
const available = ensureVmCacheAvailable({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
})
|
||||
|
||||
resolveManifest(new Response('', { status: 503 }))
|
||||
|
||||
await expect(first).resolves.toBeInstanceOf(Error)
|
||||
await available
|
||||
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
)
|
||||
expect(calls).toEqual([
|
||||
MANIFEST_URL,
|
||||
MANIFEST_URL,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('clears failed in-flight syncs so a later call can retry', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (calls.length === 1) return new Response('', { status: 503 })
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
|
||||
return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}),
|
||||
).rejects.toThrow('manifest fetch failed')
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
downloaded: [TARBALL_KEY],
|
||||
manifestPath: getCachedManifestPath(root),
|
||||
skipped: false,
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
MANIFEST_URL,
|
||||
MANIFEST_URL,
|
||||
`${CDN_BASE}/${TARBALL_KEY}`,
|
||||
])
|
||||
})
|
||||
|
||||
it('removes the partial file when sha256 verification fails', async () => {
|
||||
const badBytes = new TextEncoder().encode('bad-tarball')
|
||||
const fetchImpl = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
if (url === MANIFEST_URL) return jsonResponse(manifest)
|
||||
if (url === `${CDN_BASE}/${TARBALL_KEY}`) return new Response(badBytes)
|
||||
return new Response('', { status: 404 })
|
||||
}) as typeof fetch
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl,
|
||||
rawHostArch: 'arm64',
|
||||
}),
|
||||
).rejects.toThrow('sha256 mismatch')
|
||||
|
||||
await expect(stat(join(root, 'cache', TARBALL_KEY))).rejects.toThrow()
|
||||
await expect(
|
||||
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unsupported host architectures before fetching', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
await expect(
|
||||
ensureVmCacheSynced({
|
||||
browserosRoot: root,
|
||||
manifestUrl: MANIFEST_URL,
|
||||
fetchImpl: fakeVmCacheFetch(calls),
|
||||
rawHostArch: 'arm',
|
||||
}),
|
||||
).rejects.toThrow('unsupported host arch: arm')
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function fakeVmCacheFetch(
|
||||
calls: string[],
|
||||
opts?: { manifestUrl?: string; tarballUrl?: string },
|
||||
): typeof fetch {
|
||||
const manifestUrl = opts?.manifestUrl ?? MANIFEST_URL
|
||||
const tarballUrl = opts?.tarballUrl ?? `${CDN_BASE}/${TARBALL_KEY}`
|
||||
return (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (url === manifestUrl) return jsonResponse(manifest)
|
||||
if (url === tarballUrl) return new Response(TARBALL_BYTES)
|
||||
return new Response('', { status: 404 })
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
async function writeLocalManifest(root: string): Promise<void> {
|
||||
const path = getCachedManifestPath(root)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
}
|
||||
|
||||
async function writeLocalTarball(root: string): Promise<void> {
|
||||
const path = join(root, 'cache', TARBALL_KEY)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, TARBALL_BYTES)
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
ContainerCliError,
|
||||
ImageLoadError,
|
||||
LimaCommandError,
|
||||
ManifestMissingError,
|
||||
VmError,
|
||||
VmNotReadyError,
|
||||
VmStateCorruptedError,
|
||||
} from '../../../src/lib/vm/errors'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
|
||||
describe('VM errors', () => {
|
||||
it('keeps all VM domain errors under VmError', () => {
|
||||
const errors = [
|
||||
new VmError('base'),
|
||||
new VmNotReadyError('not ready'),
|
||||
new VmStateCorruptedError('corrupt'),
|
||||
new LimaCommandError('limactl start', 7, 'bad lima'),
|
||||
new ContainerCliError('nerdctl pull', 8, 'bad nerdctl'),
|
||||
new ImageLoadError('openclaw:v1', 'bad image'),
|
||||
new ManifestMissingError('/tmp/manifest.json'),
|
||||
]
|
||||
|
||||
for (const error of errors) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(VmError)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries command failure details', () => {
|
||||
const lima = new LimaCommandError('limactl start', 12, 'stderr text')
|
||||
const container = new ContainerCliError(
|
||||
'nerdctl pull',
|
||||
13,
|
||||
'nerdctl stderr',
|
||||
)
|
||||
|
||||
expect(lima.exitCode).toBe(12)
|
||||
expect(lima.stderr).toBe('stderr text')
|
||||
expect(container.exitCode).toBe(13)
|
||||
expect(container.stderr).toBe('nerdctl stderr')
|
||||
})
|
||||
|
||||
it('exports VM telemetry event names', () => {
|
||||
expect(VM_TELEMETRY_EVENTS.ensureReadyStart).toBe('vm.ensure_ready.start')
|
||||
expect(VM_TELEMETRY_EVENTS.downgradeDetected).toBe('vm.downgrade.detected')
|
||||
expect(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout).toBe(
|
||||
'vm.nerdctl_wait.timeout',
|
||||
)
|
||||
expect(VM_TELEMETRY_EVENTS.migrationOpenClawMoved).toBe(
|
||||
'vm.migration.openclaw_moved',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
mock,
|
||||
spyOn,
|
||||
} from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { logger } from '../../../src/lib/logger'
|
||||
import { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import { LimaCli } from '../../../src/lib/vm/lima-cli'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
import { fakeLimactl } from '../../__helpers__/fake-limactl'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
describe('LimaCli', () => {
|
||||
let tempDir: string
|
||||
let logPath: string
|
||||
let limaHome: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'lima-cli-test-'))
|
||||
logPath = join(tempDir, 'calls.log')
|
||||
limaHome = join(tempDir, 'lima-home')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses limactl list JSON output', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
name: 'browseros-vm',
|
||||
status: 'Running',
|
||||
dir: '/lima/browseros-vm',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await expect(cli.list()).resolves.toEqual([
|
||||
{ name: 'browseros-vm', status: 'Running', dir: '/lima/browseros-vm' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty VM list when limactl prints no output', async () => {
|
||||
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await expect(cli.list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('creates VMs with LIMA_HOME and the expected argv', async () => {
|
||||
const limactlPath = await fakeLimactl({ create: {} }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.create('browseros-vm', '/tmp/browseros-vm.yaml')
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
'ARGS:create --tty=false --name=browseros-vm /tmp/browseros-vm.yaml',
|
||||
)
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`LIMA_HOME:${limaHome}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('starts VMs with tty disabled', async () => {
|
||||
const limactlPath = await fakeLimactl({ start: {} }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.start('browseros-vm')
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
'ARGS:start --tty=false browseros-vm',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws LimaCommandError with stderr on non-zero exit', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ start: { stderr: 'cannot start', exit: 2 } },
|
||||
logPath,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
const error = await cli.start('browseros-vm').catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(LimaCommandError)
|
||||
expect(error.exitCode).toBe(2)
|
||||
expect(error.stderr).toBe('cannot start')
|
||||
})
|
||||
|
||||
it('does not log limactl stderr chunks by default', async () => {
|
||||
const debug = spyOn(logger, 'debug').mockImplementation(() => {})
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ start: { stderr: 'boot noise\n' } },
|
||||
logPath,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.start('browseros-vm')
|
||||
|
||||
expect(
|
||||
debug.mock.calls.some(
|
||||
([message]) => message === VM_TELEMETRY_EVENTS.limaStderrChunk,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stops and deletes VMs', async () => {
|
||||
const limactlPath = await fakeLimactl({ stop: {}, delete: {} }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.stop('browseros-vm')
|
||||
await cli.delete('browseros-vm')
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain('ARGS:stop browseros-vm')
|
||||
expect(log).toContain('ARGS:delete --force browseros-vm')
|
||||
})
|
||||
|
||||
it('runs shell commands and streams stdout and stderr', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'out\n', stderr: 'err\n' }, logPath)
|
||||
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
|
||||
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
|
||||
const lines: string[] = []
|
||||
|
||||
await expect(
|
||||
cli.shell('browseros-vm', ['nerdctl', 'ps'], {
|
||||
onStdout: (line) => lines.push(`stdout:${line}`),
|
||||
onStderr: (line) => lines.push(`stderr:${line}`),
|
||||
}),
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(lines).toContain('stdout:out')
|
||||
expect(lines).toContain('stderr:err')
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`ARGS:-F ${sshConfig} lima-browseros-vm 'nerdctl' 'ps'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('shell-quotes remote commands to preserve argument boundaries', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
|
||||
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
|
||||
|
||||
await expect(
|
||||
cli.shell('browseros-vm', ['sh', '-lc', "echo 'boundary ok'"]),
|
||||
).resolves.toBe(0)
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`ARGS:-F ${sshConfig} lima-browseros-vm 'sh' '-lc' 'echo '\\''boundary ok'\\'''`,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores shell stderr when no stderr stream handler is provided', async () => {
|
||||
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
|
||||
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const spawn = spyOn(Bun, 'spawn')
|
||||
spawn.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
exited: Promise.resolve(0),
|
||||
}) as never,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
|
||||
|
||||
await expect(
|
||||
cli.shell('browseros-vm', ['true'], {
|
||||
onStdout: () => {},
|
||||
}),
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
['ssh', '-F', sshConfig, 'lima-browseros-vm', "'true'"],
|
||||
expect.objectContaining({
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws VmNotReadyError when ssh.config is missing', async () => {
|
||||
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
|
||||
const error = await cli.shell('browseros-vm', ['true']).catch((err) => err)
|
||||
expect(error).toBeInstanceOf(VmNotReadyError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { renderLimaTemplate } from '../../../src/lib/vm/lima-config'
|
||||
|
||||
describe('renderLimaTemplate', () => {
|
||||
it('injects BrowserOS host mounts into the bundled Lima template', () => {
|
||||
const yaml = renderLimaTemplate(
|
||||
'minimumLimaVersion: 2.0.0\nmounts: []\nprobes: []\n',
|
||||
{
|
||||
vmStateDir: '/Users/me/.browseros/vm',
|
||||
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
|
||||
},
|
||||
)
|
||||
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
expect(yaml).toContain('location: "/Users/me/.browseros/vm"')
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
|
||||
expect(yaml).toContain('location: "/Users/me/.browseros/cache/vm/images"')
|
||||
expect(yaml).toContain('probes: []')
|
||||
})
|
||||
|
||||
it('fails loudly if the template no longer has the expected mount marker', () => {
|
||||
expect(() =>
|
||||
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
|
||||
vmStateDir: '/state',
|
||||
imageCacheDir: '/images',
|
||||
}),
|
||||
).toThrow('mounts: [] marker')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { ManifestMissingError } from '../../../src/lib/vm/errors'
|
||||
import {
|
||||
agentForArch,
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
type VmManifest,
|
||||
writeInstalledManifest,
|
||||
} from '../../../src/lib/vm/manifest'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'd',
|
||||
sizeBytes: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VM manifest helpers', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'browseros-vm-manifest-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the cached manifest', async () => {
|
||||
const manifestPath = join(root, 'cache', 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readCachedManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws ManifestMissingError when cached manifest is absent', async () => {
|
||||
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
|
||||
})
|
||||
|
||||
it('returns null for a missing installed manifest', async () => {
|
||||
await expect(readInstalledManifest(root)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reads the installed manifest', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readInstalledManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws on malformed installed manifest JSON', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, '{not-json')
|
||||
|
||||
await expect(readInstalledManifest(root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('writes the installed manifest atomically', async () => {
|
||||
await writeInstalledManifest(manifest, root)
|
||||
|
||||
const raw = await readFile(join(root, 'vm', 'manifest.json'), 'utf8')
|
||||
expect(JSON.parse(raw)).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('compares installed and cached versions', () => {
|
||||
const older = { ...manifest, updatedAt: '2026-04-21T00:00:00.000Z' }
|
||||
const newer = { ...manifest, updatedAt: '2026-04-23T00:00:00.000Z' }
|
||||
|
||||
expect(compareVersions(null, manifest)).toBe('fresh')
|
||||
expect(compareVersions(manifest, manifest)).toBe('same')
|
||||
expect(compareVersions(older, manifest)).toBe('upgrade')
|
||||
expect(compareVersions(newer, manifest)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('compares ISO timestamp versions with time-of-day precision', () => {
|
||||
const morning = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
}
|
||||
const afternoon = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T15:00:00.000Z',
|
||||
}
|
||||
|
||||
expect(compareVersions(morning, afternoon)).toBe('upgrade')
|
||||
expect(compareVersions(afternoon, morning)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('returns the requested agent tarball for an arch', () => {
|
||||
expect(agentForArch(manifest, 'openclaw', 'arm64')).toEqual({
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarball: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when an agent or arch is absent', () => {
|
||||
expect(() => agentForArch(manifest, 'missing', 'arm64')).toThrow(
|
||||
'missing agent',
|
||||
)
|
||||
expect(() =>
|
||||
agentForArch(manifest, 'openclaw', 'x64' as never),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
319
packages/browseros-agent/apps/server/tests/lib/vm/paths.test.ts
Normal file
319
packages/browseros-agent/apps/server/tests/lib/vm/paths.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import {
|
||||
getLegacyOpenClawDir,
|
||||
getOpenClawDir,
|
||||
} from '../../../src/lib/browseros-dir'
|
||||
import {
|
||||
detectArch,
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
getImageCacheDir,
|
||||
getInstalledManifestPath,
|
||||
getLimaHomeDir,
|
||||
getVmCacheDir,
|
||||
getVmStateDir,
|
||||
hostPathToGuest,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
} from '../../../src/lib/vm/paths'
|
||||
|
||||
describe('VM paths', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalPath = process.env.PATH
|
||||
const originalBrowserosDir = process.env.BROWSEROS_DIR
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
|
||||
if (originalBrowserosDir === undefined) {
|
||||
delete process.env.BROWSEROS_DIR
|
||||
} else {
|
||||
process.env.BROWSEROS_DIR = originalBrowserosDir
|
||||
}
|
||||
})
|
||||
|
||||
it('uses production VM directories below .browseros', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
delete process.env.BROWSEROS_DIR
|
||||
|
||||
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros', 'lima'))
|
||||
expect(getVmStateDir()).toBe(join(homedir(), '.browseros', 'vm'))
|
||||
expect(getOpenClawDir()).toBe(
|
||||
join(homedir(), '.browseros', 'vm', 'openclaw'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses development VM directories below .browseros-dev', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
delete process.env.BROWSEROS_DIR
|
||||
|
||||
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros-dev', 'lima'))
|
||||
expect(getVmStateDir()).toBe(join(homedir(), '.browseros-dev', 'vm'))
|
||||
expect(getOpenClawDir()).toBe(
|
||||
join(homedir(), '.browseros-dev', 'vm', 'openclaw'),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy OpenClaw directory addressable for migration', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
delete process.env.BROWSEROS_DIR
|
||||
|
||||
expect(getLegacyOpenClawDir()).toBe(
|
||||
join(homedir(), PATHS.BROWSEROS_DIR_NAME, PATHS.OPENCLAW_DIR_NAME),
|
||||
)
|
||||
})
|
||||
|
||||
it('builds cached and installed manifest paths', () => {
|
||||
const root = '/Users/foo/.browseros'
|
||||
|
||||
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
|
||||
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
|
||||
expect(getCachedManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/cache/vm/manifest.json',
|
||||
)
|
||||
expect(getInstalledManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/vm/manifest.json',
|
||||
)
|
||||
expect(getContainerdSocketPath(root)).toBe(
|
||||
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates mounted host paths into guest paths', () => {
|
||||
const root = '/Users/foo/.browseros'
|
||||
|
||||
expect(hostPathToGuest('/Users/foo/.browseros/vm/openclaw/x', root)).toBe(
|
||||
'/mnt/browseros/vm/openclaw/x',
|
||||
)
|
||||
expect(
|
||||
hostPathToGuest('/Users/foo/.browseros/cache/vm/images/a.tar.gz', root),
|
||||
).toBe('/mnt/browseros/cache/images/a.tar.gz')
|
||||
})
|
||||
|
||||
it('rejects unmapped host paths', () => {
|
||||
expect(() =>
|
||||
hostPathToGuest('/tmp/other', '/Users/foo/.browseros'),
|
||||
).toThrow('not under any known guest mount')
|
||||
})
|
||||
|
||||
it('detects supported host architectures', () => {
|
||||
expect(detectArch('arm64')).toBe('arm64')
|
||||
expect(detectArch('x64')).toBe('x64')
|
||||
})
|
||||
|
||||
it('rejects unsupported host architectures', () => {
|
||||
expect(() => detectArch('ppc64' as NodeJS.Architecture)).toThrow(
|
||||
'unsupported host arch',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the bundled limactl executable', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'limactl-resources-'))
|
||||
const limactlPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'bin',
|
||||
'limactl',
|
||||
)
|
||||
const armGuestAgentPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'share',
|
||||
'lima',
|
||||
'lima-guestagent.Linux-aarch64.gz',
|
||||
)
|
||||
const x64GuestAgentPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'share',
|
||||
'lima',
|
||||
'lima-guestagent.Linux-x86_64.gz',
|
||||
)
|
||||
await mkdir(dirname(limactlPath), { recursive: true })
|
||||
await mkdir(dirname(armGuestAgentPath), { recursive: true })
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
await writeFile(armGuestAgentPath, 'guest-agent\n')
|
||||
await writeFile(x64GuestAgentPath, 'guest-agent\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl(resourcesDir)).toBe(limactlPath)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates the x64 bundled Lima guest agent path', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'limactl-x64-resources-'))
|
||||
const limactlPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'bin',
|
||||
'limactl',
|
||||
)
|
||||
const guestAgentPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'share',
|
||||
'lima',
|
||||
'lima-guestagent.Linux-x86_64.gz',
|
||||
)
|
||||
await mkdir(dirname(limactlPath), { recursive: true })
|
||||
await mkdir(dirname(guestAgentPath), { recursive: true })
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
await writeFile(guestAgentPath, 'guest-agent\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl(resourcesDir, 'x64')).toBe(limactlPath)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws with a runtime packaging hint when the bundled Lima guest agent is missing', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const resourcesDir = await mkdtemp(
|
||||
join(tmpdir(), 'missing-lima-guest-agent-'),
|
||||
)
|
||||
const limactlPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'bin',
|
||||
'limactl',
|
||||
)
|
||||
await mkdir(dirname(limactlPath), { recursive: true })
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
|
||||
try {
|
||||
expect(() => resolveBundledLimactl(resourcesDir)).toThrow(
|
||||
'bundled Lima guest agent not found',
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses PATH limactl in development mode', async () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
const binDir = await createFakeLimactlPath()
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe(
|
||||
join(binDir, 'limactl'),
|
||||
)
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses PATH limactl in test mode', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
const binDir = await createFakeLimactlPath()
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe(
|
||||
join(binDir, 'limactl'),
|
||||
)
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws with a brew install hint when host limactl is missing', async () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
const binDir = await mkdtemp(join(tmpdir(), 'missing-host-limactl-'))
|
||||
process.env.PATH = binDir
|
||||
|
||||
try {
|
||||
expect(() => resolveBundledLimactl('/tmp/missing-dev-resources')).toThrow(
|
||||
'brew install lima',
|
||||
)
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws with a build-tools hint when bundled limactl is missing', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(() => resolveBundledLimactl('/tmp/missing-resources')).toThrow(
|
||||
'build-tools README',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the bundled Lima template', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'lima-template-'))
|
||||
const templatePath = join(resourcesDir, 'vm', 'browseros-vm.yaml')
|
||||
await mkdir(dirname(templatePath), { recursive: true })
|
||||
await writeFile(templatePath, 'mounts: []\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the source Lima template from a package workspace in test mode', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), 'lima-source-template-'))
|
||||
const resourcesDir = join(workspaceDir, 'packages', 'browseros-agent')
|
||||
const templatePath = join(
|
||||
workspaceDir,
|
||||
'packages',
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
await mkdir(resourcesDir, { recursive: true })
|
||||
await mkdir(dirname(templatePath), { recursive: true })
|
||||
await writeFile(templatePath, 'mounts: []\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function createFakeLimactlPath(): Promise<string> {
|
||||
const binDir = await mkdtemp(join(tmpdir(), 'host-limactl-'))
|
||||
const limactlPath = join(binDir, 'limactl')
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
await chmod(limactlPath, 0o755)
|
||||
process.env.PATH = binDir
|
||||
return binDir
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../../../src/lib/logger'
|
||||
import { VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getInstalledManifestPath,
|
||||
VM_NAME,
|
||||
} from '../../../src/lib/vm/paths'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
|
||||
import { fakeLimactl } from '../../__helpers__/fake-limactl'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VmRuntime', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
let logPath: string
|
||||
let templatePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/vmrt-')
|
||||
limaHome = join(root, 'lima')
|
||||
logPath = join(root, 'limactl.log')
|
||||
templatePath = join(root, 'browseros-vm.yaml')
|
||||
await writeCachedManifest(root)
|
||||
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
|
||||
await expect(
|
||||
readFile(getInstalledManifestPath(root), 'utf8'),
|
||||
).resolves.toContain(manifest.updatedAt)
|
||||
await expect(
|
||||
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
|
||||
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
})
|
||||
|
||||
it('fills a missing VM cache before reading the cached manifest', async () => {
|
||||
await rm(getCachedManifestPath(root), { force: true })
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const ensureCacheAvailable = mock(async () => {
|
||||
await writeCachedManifest(root)
|
||||
})
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
ensureCacheAvailable,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
|
||||
await expect(
|
||||
readFile(getInstalledManifestPath(root), 'utf8'),
|
||||
).resolves.toContain(manifest.updatedAt)
|
||||
})
|
||||
|
||||
it('surfaces cache sync failures before reading a missing manifest', async () => {
|
||||
await rm(getCachedManifestPath(root), { force: true })
|
||||
const ensureCacheAvailable = mock(async () => {
|
||||
throw new Error('cache offline')
|
||||
})
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath: 'unused',
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
ensureCacheAvailable,
|
||||
})
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('cache offline')
|
||||
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns fast when the VM is already running and manifests match', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
create: { stderr: 'should not create', exit: 9 },
|
||||
start: { stderr: 'should not start', exit: 9 },
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain('ARGS:list --format json')
|
||||
expect(log).not.toContain('ARGS:create')
|
||||
expect(log).not.toContain('ARGS:start')
|
||||
})
|
||||
|
||||
it('starts an existing stopped VM without recreating it', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Stopped', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
start: {},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
expect(log).not.toContain('ARGS:create')
|
||||
})
|
||||
|
||||
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
stop: {},
|
||||
delete: {},
|
||||
create: {},
|
||||
start: {},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await fakeRootfulThenReadySsh(root, logPath)
|
||||
await writeSshConfig(limaHome)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
|
||||
expect(log).toContain(
|
||||
`lima-${VM_NAME} 'sh' '-lc' 'cat /etc/browseros-vm-version 2>/dev/null || true'`,
|
||||
)
|
||||
expect(log).toContain(`ARGS:stop ${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:delete --force ${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
})
|
||||
|
||||
it('treats stopVm as idempotent when the VM is already stopped', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ stop: { stderr: 'instance is not running', exit: 1 } },
|
||||
logPath,
|
||||
)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.stopVm()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a bundled Lima template for fresh VM provisioning', async () => {
|
||||
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('Lima template path')
|
||||
})
|
||||
|
||||
it('throws VmNotReadyError when rootless nerdctl never becomes ready', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareFailingSsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
readinessTimeoutMs: 10,
|
||||
readinessPollMs: 1,
|
||||
})
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
|
||||
})
|
||||
|
||||
it('exposes a reset stub with a follow-up-plan message', async () => {
|
||||
const limactlPath = await fakeLimactl({}, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.reset('bad disk')).rejects.toThrow(
|
||||
'VmRuntime.reset is not implemented yet',
|
||||
)
|
||||
})
|
||||
|
||||
it('logs upgrade mismatch and preserves the installed manifest until upgrade happens', async () => {
|
||||
await writeInstalledManifest(root, '2026-04-21T00:00:00.000Z')
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
const originalWarn = logger.warn
|
||||
const warnings: Array<{
|
||||
message: string
|
||||
meta?: Record<string, unknown>
|
||||
}> = []
|
||||
logger.warn = (message, meta) => warnings.push({ message, meta })
|
||||
|
||||
try {
|
||||
await runtime.ensureReady()
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
expect(warnings).toContainEqual({
|
||||
message: VM_TELEMETRY_EVENTS.upgradeDetected,
|
||||
meta: {
|
||||
from: '2026-04-21T00:00:00.000Z',
|
||||
to: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-21T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('logs downgrade mismatch and preserves a newer installed manifest', async () => {
|
||||
await writeInstalledManifest(root, '2026-04-23T00:00:00.000Z')
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
const originalWarn = logger.warn
|
||||
const warnings: Array<{
|
||||
message: string
|
||||
meta?: Record<string, unknown>
|
||||
}> = []
|
||||
logger.warn = (message, meta) => warnings.push({ message, meta })
|
||||
|
||||
try {
|
||||
await runtime.ensureReady()
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
expect(warnings).toContainEqual({
|
||||
message: VM_TELEMETRY_EVENTS.downgradeDetected,
|
||||
meta: {
|
||||
from: '2026-04-23T00:00:00.000Z',
|
||||
to: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareFailingSsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
readinessTimeoutMs: 10,
|
||||
readinessPollMs: 1,
|
||||
})
|
||||
let resetCalled = false
|
||||
runtime.reset = async () => {
|
||||
resetCalled = true
|
||||
throw new Error('reset called')
|
||||
}
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
|
||||
expect(resetCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates runCommand through ssh', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath: 'unused',
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(
|
||||
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves and caches the VM default gateway through ssh', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{
|
||||
stdout:
|
||||
'default via 192.168.5.2 dev eth0 proto dhcp src 192.168.5.15 metric 100\n',
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath: 'unused',
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
|
||||
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log.match(/'ip' '-4' 'route' 'show' 'default'/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
async function writeCachedManifest(root: string): Promise<void> {
|
||||
const manifestPath = getCachedManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
}
|
||||
|
||||
async function writeInstalledManifest(
|
||||
root: string,
|
||||
updatedAt = manifest.updatedAt,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify({ ...manifest, updatedAt })}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async function readInstalledUpdatedAt(root: string): Promise<string> {
|
||||
const raw = await readFile(getInstalledManifestPath(root), 'utf8')
|
||||
return (JSON.parse(raw) as VmManifest).updatedAt
|
||||
}
|
||||
|
||||
async function prepareReadySsh(
|
||||
limaHome: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
await writeSshConfig(limaHome)
|
||||
return fakeSsh({}, logPath)
|
||||
}
|
||||
|
||||
async function prepareFailingSsh(
|
||||
limaHome: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
await writeSshConfig(limaHome)
|
||||
return fakeSsh(
|
||||
{
|
||||
stderr:
|
||||
'rootless containerd not running? stat /run/user/501/containerd-rootless: no such file or directory',
|
||||
exit: 1,
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
}
|
||||
|
||||
async function writeSshConfig(limaHome: string): Promise<void> {
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
|
||||
}
|
||||
|
||||
async function fakeRootfulThenReadySsh(
|
||||
root: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
const path = join(root, 'ssh-rootful-then-ready')
|
||||
const counterPath = join(root, 'ssh-rootful-then-ready.count')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "ARGS:$*" >> "${logPath}"
|
||||
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
|
||||
next=$((count + 1))
|
||||
printf '%s' "$next" > "${counterPath}"
|
||||
case "$count" in
|
||||
0)
|
||||
echo "rootless containerd not running" >&2
|
||||
exit 1
|
||||
;;
|
||||
1)
|
||||
printf 'runtime:containerd\\n'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const config = {
|
||||
executionDir: '/tmp/browseros-execution',
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
}
|
||||
|
||||
describe('Application.start', () => {
|
||||
@@ -23,83 +25,15 @@ describe('Application.start', () => {
|
||||
})
|
||||
|
||||
it('starts with the CDP backend only', async () => {
|
||||
const apiServer = await import('../src/api/server')
|
||||
const browserModule = await import('../src/browser/browser')
|
||||
const cdpModule = await import('../src/browser/backends/cdp')
|
||||
const browserosDir = await import('../src/lib/browseros-dir')
|
||||
const dbModule = await import('../src/lib/db')
|
||||
const identityModule = await import('../src/lib/identity')
|
||||
const loggerModule = await import('../src/lib/logger')
|
||||
const metricsModule = await import('../src/lib/metrics')
|
||||
const sentryModule = await import('../src/lib/sentry')
|
||||
const soulModule = await import('../src/lib/soul')
|
||||
const openclawService = await import(
|
||||
'../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const podmanRuntime = await import(
|
||||
'../src/api/services/openclaw/podman-runtime'
|
||||
)
|
||||
const migrateModule = await import('../src/skills/migrate')
|
||||
const remoteSyncModule = await import('../src/skills/remote-sync')
|
||||
|
||||
const createHttpServer = spyOn(apiServer, 'createHttpServer')
|
||||
createHttpServer.mockImplementation(async () => ({}) as never)
|
||||
|
||||
const cdpConnect = mock(async () => {})
|
||||
spyOn(cdpModule.CdpBackend.prototype, 'connect').mockImplementation(
|
||||
const {
|
||||
Application,
|
||||
browserModule,
|
||||
cdpConnect,
|
||||
)
|
||||
|
||||
spyOn(browserosDir, 'cleanOldSessions').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'ensureBrowserosDir').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'writeServerConfig').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'removeServerConfigSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(dbModule, 'initializeDb').mockImplementation(() => ({}) as never)
|
||||
spyOn(identityModule.identity, 'initialize').mockImplementation(() => {})
|
||||
spyOn(identityModule.identity, 'getBrowserOSId').mockImplementation(
|
||||
() => 'browseros-id',
|
||||
)
|
||||
|
||||
const loggerInfo = spyOn(loggerModule.logger, 'info').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
const loggerWarn = spyOn(loggerModule.logger, 'warn').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
|
||||
const loggerError = spyOn(loggerModule.logger, 'error').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
spyOn(loggerModule.logger, 'setLogFile').mockImplementation(() => {})
|
||||
|
||||
spyOn(metricsModule.metrics, 'initialize').mockImplementation(() => {})
|
||||
spyOn(metricsModule.metrics, 'isEnabled').mockImplementation(() => true)
|
||||
spyOn(metricsModule.metrics, 'log').mockImplementation(() => {})
|
||||
|
||||
spyOn(sentryModule.Sentry, 'setContext').mockImplementation(() => {})
|
||||
spyOn(sentryModule.Sentry, 'setUser').mockImplementation(() => {})
|
||||
spyOn(sentryModule.Sentry, 'captureException').mockImplementation(() => {})
|
||||
|
||||
spyOn(soulModule, 'seedSoulTemplate').mockImplementation(async () => {})
|
||||
spyOn(migrateModule, 'migrateBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'syncBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(podmanRuntime, 'configurePodmanRuntime').mockImplementation(() => {})
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
createHttpServer,
|
||||
loggerError,
|
||||
loggerInfo,
|
||||
loggerWarn,
|
||||
} = await setupApplicationTest()
|
||||
const app = new Application(config)
|
||||
|
||||
await app.start()
|
||||
@@ -116,4 +50,170 @@ describe('Application.start', () => {
|
||||
expect(loggerWarn).not.toHaveBeenCalled()
|
||||
expect(loggerError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts VM cache prefetch without blocking HTTP startup', async () => {
|
||||
const { Application, createHttpServer, prefetchVmCache } =
|
||||
await setupApplicationTest()
|
||||
let resolvePrefetch: (value: {
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}) => void = () => {}
|
||||
const pendingPrefetch = new Promise<{
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}>((resolve) => {
|
||||
resolvePrefetch = resolve
|
||||
})
|
||||
prefetchVmCache.mockImplementation(() => pendingPrefetch)
|
||||
|
||||
const app = new Application(config)
|
||||
const startPromise = app.start()
|
||||
const completedBeforePrefetch = await Promise.race([
|
||||
startPromise.then(() => true),
|
||||
Bun.sleep(25).then(() => false),
|
||||
])
|
||||
resolvePrefetch({
|
||||
downloaded: [],
|
||||
manifestPath: '/tmp/manifest.json',
|
||||
skipped: true,
|
||||
})
|
||||
await startPromise
|
||||
|
||||
expect(completedBeforePrefetch).toBe(true)
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(prefetchVmCache).toHaveBeenCalledWith({
|
||||
manifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs VM cache prefetch failures without failing startup', async () => {
|
||||
const { Application, createHttpServer, loggerWarn, prefetchVmCache } =
|
||||
await setupApplicationTest()
|
||||
prefetchVmCache.mockImplementation(() =>
|
||||
Promise.reject(new Error('cache offline')),
|
||||
)
|
||||
const app = new Application(config)
|
||||
|
||||
await app.start()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(loggerWarn).toHaveBeenCalledWith(
|
||||
'BrowserOS VM cache prefetch failed',
|
||||
{
|
||||
error: 'cache offline',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('skips VM cache prefetch when disabled', async () => {
|
||||
const { Application, prefetchVmCache } = await setupApplicationTest()
|
||||
const app = new Application({ ...config, vmCachePrefetch: false })
|
||||
|
||||
await app.start()
|
||||
|
||||
expect(prefetchVmCache).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
async function setupApplicationTest() {
|
||||
const apiServer = await import('../src/api/server')
|
||||
const browserModule = await import('../src/browser/browser')
|
||||
const cdpModule = await import('../src/browser/backends/cdp')
|
||||
const openclawService = await import(
|
||||
'../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const browserosDir = await import('../src/lib/browseros-dir')
|
||||
const cacheSync = await import('../src/lib/vm/cache-sync')
|
||||
const dbModule = await import('../src/lib/db')
|
||||
const identityModule = await import('../src/lib/identity')
|
||||
const loggerModule = await import('../src/lib/logger')
|
||||
const metricsModule = await import('../src/lib/metrics')
|
||||
const sentryModule = await import('../src/lib/sentry')
|
||||
const soulModule = await import('../src/lib/soul')
|
||||
const migrateModule = await import('../src/skills/migrate')
|
||||
const remoteSyncModule = await import('../src/skills/remote-sync')
|
||||
|
||||
const createHttpServer = spyOn(apiServer, 'createHttpServer')
|
||||
createHttpServer.mockImplementation(async () => ({}) as never)
|
||||
|
||||
const cdpConnect = mock(async () => {})
|
||||
spyOn(cdpModule.CdpBackend.prototype, 'connect').mockImplementation(
|
||||
cdpConnect,
|
||||
)
|
||||
|
||||
spyOn(browserosDir, 'cleanOldSessions').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'ensureBrowserosDir').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'writeServerConfig').mockImplementation(async () => {})
|
||||
spyOn(browserosDir, 'removeServerConfigSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(dbModule, 'initializeDb').mockImplementation(() => ({}) as never)
|
||||
spyOn(identityModule.identity, 'initialize').mockImplementation(() => {})
|
||||
spyOn(identityModule.identity, 'getBrowserOSId').mockImplementation(
|
||||
() => 'browseros-id',
|
||||
)
|
||||
|
||||
const loggerInfo = spyOn(loggerModule.logger, 'info').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
const loggerWarn = spyOn(loggerModule.logger, 'warn').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
|
||||
const loggerError = spyOn(loggerModule.logger, 'error').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
spyOn(loggerModule.logger, 'setLogFile').mockImplementation(() => {})
|
||||
|
||||
spyOn(metricsModule.metrics, 'initialize').mockImplementation(() => {})
|
||||
spyOn(metricsModule.metrics, 'isEnabled').mockImplementation(() => true)
|
||||
spyOn(metricsModule.metrics, 'log').mockImplementation(() => {})
|
||||
|
||||
spyOn(sentryModule.Sentry, 'setContext').mockImplementation(() => {})
|
||||
spyOn(sentryModule.Sentry, 'setUser').mockImplementation(() => {})
|
||||
spyOn(sentryModule.Sentry, 'captureException').mockImplementation(() => {})
|
||||
|
||||
spyOn(soulModule, 'seedSoulTemplate').mockImplementation(async () => {})
|
||||
spyOn(migrateModule, 'migrateBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'syncBuiltinSkills').mockImplementation(
|
||||
async () => {},
|
||||
)
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const prefetchVmCache = spyOn(cacheSync, 'prefetchVmCache')
|
||||
prefetchVmCache.mockImplementation(async () => ({
|
||||
downloaded: [],
|
||||
manifestPath: '/tmp/manifest.json',
|
||||
skipped: true,
|
||||
}))
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
return {
|
||||
Application,
|
||||
browserModule,
|
||||
cdpConnect,
|
||||
createHttpServer,
|
||||
loggerError,
|
||||
loggerInfo,
|
||||
loggerWarn,
|
||||
prefetchVmCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { RemoteLazyMonitoringJudgeClient } from '../src/monitoring/judge/llm-judge'
|
||||
import {
|
||||
type LazyMonitoringJudgeClient,
|
||||
LazyMonitoringJudgeService,
|
||||
} from '../src/monitoring/judge/service'
|
||||
import type {
|
||||
LazyMonitoringJudgeInput,
|
||||
LazyMonitoringJudgment,
|
||||
} from '../src/monitoring/judge/types'
|
||||
|
||||
function buildInput(
|
||||
overrides: Partial<LazyMonitoringJudgeInput> = {},
|
||||
): LazyMonitoringJudgeInput {
|
||||
return {
|
||||
run: {
|
||||
monitoringSessionId: '123e4567-e89b-12d3-a456-426614174111',
|
||||
agentId: 'agent-1',
|
||||
sessionKey: 'session-1',
|
||||
originalPrompt: 'summarize my inbox',
|
||||
chatHistory: [{ role: 'user', content: 'summarize my inbox' }],
|
||||
startedAt: '2026-04-20T15:59:03.630Z',
|
||||
source: 'debug',
|
||||
},
|
||||
priorToolCalls: [],
|
||||
currentToolCall: {
|
||||
monitoringSessionId: '123e4567-e89b-12d3-a456-426614174111',
|
||||
agentId: 'agent-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'get_page_content',
|
||||
source: 'browser-tool',
|
||||
args: { page: 1 },
|
||||
startedAt: '2026-04-20T15:59:03.630Z',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildJudgment(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
overrides: Partial<LazyMonitoringJudgment> = {},
|
||||
): LazyMonitoringJudgment {
|
||||
return {
|
||||
monitoringSessionId: input.run.monitoringSessionId,
|
||||
agentId: input.run.agentId,
|
||||
toolCallId: input.currentToolCall.toolCallId,
|
||||
toolName: input.currentToolCall.toolName,
|
||||
verdict: 'safe',
|
||||
summary: 'safe',
|
||||
destructive: false,
|
||||
shouldInterrupt: false,
|
||||
mode: 'llm',
|
||||
categories: [],
|
||||
matchedIntentCategories: [],
|
||||
policyDimensions: [],
|
||||
policyVersion: 'lazy-monitoring-judge/v1',
|
||||
model: 'test-model',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('LazyMonitoringJudgeService', () => {
|
||||
it('sends every call to the configured judge client', async () => {
|
||||
const calls: LazyMonitoringJudgeInput[] = []
|
||||
const client: LazyMonitoringJudgeClient = {
|
||||
judge: async (input) => {
|
||||
calls.push(input)
|
||||
return buildJudgment(input)
|
||||
},
|
||||
}
|
||||
|
||||
const judgment = await new LazyMonitoringJudgeService(client).evaluate(
|
||||
buildInput(),
|
||||
)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.currentToolCall.toolName).toBe('get_page_content')
|
||||
expect(judgment.mode).toBe('llm')
|
||||
})
|
||||
|
||||
it('returns the remote judge result without local rewriting', async () => {
|
||||
const client: LazyMonitoringJudgeClient = {
|
||||
judge: async (input) =>
|
||||
buildJudgment(input, {
|
||||
verdict: 'unsafe',
|
||||
summary: 'remote result',
|
||||
destructive: true,
|
||||
shouldInterrupt: true,
|
||||
policyDimensions: ['destructive_action', 'scope_mismatch'],
|
||||
}),
|
||||
}
|
||||
|
||||
const judgment = await new LazyMonitoringJudgeService(client).evaluate(
|
||||
buildInput(),
|
||||
)
|
||||
|
||||
expect(judgment.verdict).toBe('unsafe')
|
||||
expect(judgment.summary).toBe('remote result')
|
||||
expect(judgment.policyDimensions).toEqual([
|
||||
'destructive_action',
|
||||
'scope_mismatch',
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when the judge client is not configured', async () => {
|
||||
await expect(
|
||||
new LazyMonitoringJudgeService().evaluate(buildInput()),
|
||||
).rejects.toThrow('lazy monitoring judge is not configured')
|
||||
})
|
||||
|
||||
it('sends only the current prompt, previous prompt, current tool call, and previous tool call to the remote judge', async () => {
|
||||
const input = buildInput({
|
||||
run: {
|
||||
monitoringSessionId: '123e4567-e89b-12d3-a456-426614174111',
|
||||
agentId: 'agent-1',
|
||||
sessionKey: 'session-1',
|
||||
originalPrompt: 'click on the first product',
|
||||
chatHistory: [
|
||||
{ role: 'user', content: 'open amazon cart' },
|
||||
{ role: 'assistant', content: 'done' },
|
||||
],
|
||||
startedAt: '2026-04-20T15:59:03.630Z',
|
||||
source: 'debug',
|
||||
},
|
||||
priorToolCalls: [
|
||||
{
|
||||
monitoringSessionId: '123e4567-e89b-12d3-a456-426614174111',
|
||||
agentId: 'agent-1',
|
||||
toolCallId: 'tool-prev',
|
||||
toolName: 'take_snapshot',
|
||||
toolDescription: 'Take a snapshot',
|
||||
source: 'browser-tool',
|
||||
args: { page: 2 },
|
||||
output: { content: [{ type: 'text', text: '[12] Product 1' }] },
|
||||
startedAt: '2026-04-20T15:59:02.000Z',
|
||||
finishedAt: '2026-04-20T15:59:03.000Z',
|
||||
durationMs: 1000,
|
||||
},
|
||||
],
|
||||
currentToolCall: {
|
||||
monitoringSessionId: '123e4567-e89b-12d3-a456-426614174111',
|
||||
agentId: 'agent-1',
|
||||
toolCallId: 'tool-current',
|
||||
toolName: 'click',
|
||||
toolDescription: 'Click an element',
|
||||
source: 'browser-tool',
|
||||
args: { page: 2, element: 12, button: 'left' },
|
||||
startedAt: '2026-04-20T15:59:03.630Z',
|
||||
},
|
||||
})
|
||||
|
||||
let payload: Record<string, unknown> | undefined
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const requestBody =
|
||||
typeof init?.body === 'string' ? JSON.parse(init.body) : null
|
||||
const userMessage = requestBody?.messages?.[1]?.content
|
||||
payload =
|
||||
typeof userMessage === 'string' ? JSON.parse(userMessage) : undefined
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
verdict: 'safe',
|
||||
summary: 'ok',
|
||||
policyDimensions: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const judgment = await new RemoteLazyMonitoringJudgeClient({
|
||||
provider: 'openrouter',
|
||||
model: 'test-model',
|
||||
baseUrl: 'https://example.com',
|
||||
apiKey: 'test-key',
|
||||
timeoutMs: 10_000,
|
||||
}).judge(input)
|
||||
|
||||
expect(judgment.verdict).toBe('safe')
|
||||
expect(payload).toEqual({
|
||||
currentUserPrompt: 'click on the first product',
|
||||
previousUserPrompt: 'open amazon cart',
|
||||
previousToolCall: {
|
||||
toolCallId: 'tool-prev',
|
||||
toolName: 'take_snapshot',
|
||||
toolDescription: 'Take a snapshot',
|
||||
source: 'browser-tool',
|
||||
args: { page: 2 },
|
||||
output: { content: [{ type: 'text', text: '[12] Product 1' }] },
|
||||
error: undefined,
|
||||
},
|
||||
currentToolCall: {
|
||||
toolCallId: 'tool-current',
|
||||
toolName: 'click',
|
||||
toolDescription: 'Click an element',
|
||||
source: 'browser-tool',
|
||||
args: {
|
||||
page: 2,
|
||||
element: 12,
|
||||
button: 'left',
|
||||
lazyMonitoringContext: {
|
||||
element: {
|
||||
id: 12,
|
||||
lastSnapshotLine: '[12] Product 1',
|
||||
matchedFromToolCallId: 'tool-prev',
|
||||
matchedFromToolName: 'take_snapshot',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user