Compare commits

...

21 Commits

Author SHA1 Message Date
Dani Akash
c647310510 fix: reset machineReady on stop and prevent runCommand deadlock
- Reset machineReady=false in stopMachine() so ensureReady() properly
  restarts the machine after it was stopped
- Use stdout/stderr 'ignore' when no onOutput handler is provided to
  prevent pipe buffer deadlock during shutdown
2026-04-02 17:30:54 +05:30
shivammittal274
fe704e6c5b fix: only stop Podman machine if no non-BrowserOS containers are running 2026-04-02 02:36:46 +05:30
shivammittal274
7ddd5e4f58 feat: add Podman lifecycle management — shutdown hook, pre-start on launch, stop on last agent delete 2026-04-02 02:33:49 +05:30
shivammittal274
7ca6961922 Revert "fix: add default models per provider as fallback when modelId not specified"
This reverts commit d99d0e0ee6.
2026-04-02 02:22:27 +05:30
shivammittal274
d99d0e0ee6 fix: add default models per provider as fallback when modelId not specified 2026-04-02 02:21:49 +05:30
shivammittal274
4339d6955a fix: ensure Podman machine is ready before start/stop/delete operations 2026-04-02 02:04:32 +05:30
shivammittal274
1d12a0d579 feat: update agents UI — replace Docker references with Podman 2026-04-02 01:56:28 +05:30
shivammittal274
07764f93f0 feat: migrate agents.ts from Docker CLI to PodmanRuntime 2026-04-02 01:54:35 +05:30
shivammittal274
7386234542 feat: add PodmanRuntime module for machine lifecycle and container operations 2026-04-02 01:52:00 +05:30
Dani Akash
a4e5ad947d fix: write openclaw.json directly instead of running config set commands
Replace 4 separate 'docker compose run ... config set' commands with a
single openclaw.json file write. This is faster (no ephemeral containers)
and correctly configures custom OpenAI-compatible providers.

For built-in providers (anthropic, openai, google, etc.):
- Sets env.<ENV_VAR> with the API key
- Sets agents.defaults.model.primary to <provider>/<modelId>

For custom OpenAI-compatible providers (browseros, moonshot, etc.):
- Defines the provider in models.providers with baseUrl, apiKey, and
  api: "openai-completions"
- Registers the model in the provider's models array
- Sets agents.defaults.model.primary to <providerId>/<modelId>

Also configures gateway.mode, controlUi.allowedOrigins, and
chatCompletions.enabled in the same config file.
2026-04-01 20:24:55 +05:30
Dani Akash
ef0a665da4 fix: set default model during agent creation to match configured provider
OpenClaw defaults to anthropic/claude-opus-4-6 even when a different
provider is configured via env vars. Now sets agents.defaults.model.primary
during config initialization to match the selected provider.

For direct providers: <providerType>/<modelId> (e.g. anthropic/claude-sonnet-4-6)
For OpenAI-compatible: openai/<modelId> (e.g. openai/accounts/fireworks/models/kimi-k2p5)

Also passes modelId from the frontend provider config to the server.
2026-04-01 17:52:56 +05:30
Dani Akash
dfb3e8ca74 fix: broaden LLM provider compatibility for agent creation
- Accept any provider with an API key, excluding only OAuth-only types
- Fall back to OPENAI_API_KEY + OPENAI_BASE_URL for providers without
  a direct OpenClaw env var mapping (e.g. browseros, openai-compatible)
- Support multiple extra env vars in docker-compose generation
2026-04-01 17:48:24 +05:30
Dani Akash
12f03af32e feat: add chat interface and LLM provider selection for agents
Chat:
- Add POST /agents/:id/chat proxy endpoint that forwards to OpenClaw's
  OpenAI-compatible /v1/chat/completions API with SSE streaming
- Enable chatCompletions API during agent creation
- Add AgentChat component with message bubbles, streaming, auto-scroll
- Chat button on running agent cards, inline chat view

LLM Provider:
- Reuse BrowserOS LLM provider configs via useLlmProviders()
- Provider dropdown in create dialog filtered to OpenClaw-compatible types
- API key injected into docker-compose.yml as environment variable
- Pre-selects user's default provider
2026-04-01 17:42:14 +05:30
Dani Akash
f8880c0b4f feat: persist agent instances to ~/.browseros/agents.json
- Add agents.json persistence using getBrowserosDir() from existing helper
- Store id, name, status, port, dir, token, createdAt, and error
- Load agents from disk on server startup (loadAgents on module init)
- Save to disk on every status change (create, running, stopped, error, delete)
- Persist gateway token so it survives server restarts
- Extract helper functions (composeEnv, updateStatus, dumpContainerLogs,
  runConfigSet) to reduce complexity and duplication
- Runtime-only data (logs, logListeners) is not persisted
2026-04-01 17:16:14 +05:30
Dani Akash
afa1e6b868 fix: pass allowedOrigins as JSON array and deduplicate docker logs
- allowedOrigins expects an array, not a comma-separated string.
  Now passes JSON.stringify(["http://127.0.0.1:<port>", ...])
- Deduplicate stdout/stderr output from docker compose commands.
  Docker writes the same progress messages to both streams, causing
  every log line to appear twice. Now uses a Set to skip duplicates.
2026-04-01 16:32:33 +05:30
Dani Akash
88b591849c fix: set gateway.controlUi.allowedOrigins for non-loopback bind
The gateway is bound to 'lan' (non-loopback), which requires explicit
allowed origins for the Control UI. Set http://127.0.0.1:<port> and
http://localhost:<port> as allowed origins during config initialization.
2026-04-01 16:28:39 +05:30
Dani Akash
d66e789e7e fix: initialize gateway config before starting OpenClaw container
Run 'docker compose run --rm openclaw-gateway config set gateway.mode local'
before 'docker compose up -d'. Without this, the gateway exits immediately
with "Missing config" because the mounted config directory is empty.
2026-04-01 16:25:13 +05:30
Dani Akash
b8dc70a26b fix: dump container logs on health check failure and errors
When the gateway fails to become healthy or any error occurs during
agent creation, fetch the last 50 lines of container logs via
'docker compose logs' and stream them into the agent's log buffer.
This makes it visible in the logs dialog what actually went wrong
inside the container.
2026-04-01 16:19:39 +05:30
Dani Akash
18fc173dfe feat: generate docker-compose.yml directly and add SSE log streaming
- Replace setup script approach with direct docker-compose.yml generation
  using the official OpenClaw image (ghcr.io/openclaw/openclaw:latest)
- Add SSE streaming endpoint GET /agents/:id/logs for real-time logs
- Add AgentLogsDialog with terminal-style log viewer via EventSource
- Add "View Logs" button on every agent card
- Auto-open logs dialog when a new agent is created
- Log buffer capped at 1000 lines per instance
2026-04-01 16:16:05 +05:30
Dani Akash
a535cb7d86 fix: use official OpenClaw Docker setup script for agent creation
- Curl setup.sh and docker-compose.yml from the OpenClaw repo
- Run the official setup script with OPENCLAW_IMAGE env var set
  to ghcr.io/openclaw/openclaw:latest
- Each agent gets its own directory under ~/.browseros/agents/<name>/
- Use docker compose for lifecycle management (stop/start/down)
- Clean up agent directory on delete
2026-03-31 19:07:38 +05:30
Dani Akash
f962512828 feat: add Agents settings page for OpenClaw Docker orchestration
- Add "Agents" sidebar item in Settings with Cpu icon
- Add /settings/agents route with AgentsPage component
- Add /agents server API routes for Docker container lifecycle
- AgentsPage UI with Docker detection, create dialog, instance cards,
  start/stop/delete controls, and auto-polling
2026-03-31 19:03:18 +05:30
8 changed files with 1781 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import {
BookOpen,
Bot,
Compass,
Cpu,
CreditCard,
GitBranch,
MessageSquare,
@@ -80,6 +81,7 @@ const primarySettingsSections: NavSection[] = [
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{ name: 'Agents', to: '/settings/agents', icon: Cpu },
{
name: 'Usage & Billing',
to: '/settings/usage',

View File

@@ -9,6 +9,7 @@ import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
import { FeaturesPage } from '../onboarding/features/Features'
import { Onboarding } from '../onboarding/index/Onboarding'
import { StepsLayout } from '../onboarding/steps/StepsLayout'
import { AgentsPage } from './agents/AgentsPage'
import { AISettingsPage } from './ai-settings/AISettingsPage'
import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
@@ -104,6 +105,7 @@ export const App: FC = () => {
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="usage" element={<UsagePage />} />
</Route>
</Route>

View File

@@ -0,0 +1,216 @@
import { ArrowLeft, Loader2, Send } from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { cn } from '@/lib/utils'
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
let msgCounter = 0
function nextMsgId(): string {
return `msg-${++msgCounter}`
}
interface AgentChatProps {
agentId: string
agentName: string
onBack: () => void
}
export const AgentChat: FC<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
const messagesLength = messages.length
const lastMessageContent = messages[messages.length - 1]?.content ?? ''
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages and content updates
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messagesLength, lastMessageContent])
const sendMessage = async () => {
const text = input.trim()
if (!text || streaming) return
setInput('')
setMessages((prev) => [
...prev,
{ id: nextMsgId(), role: 'user', content: text },
])
setStreaming(true)
// Add an empty assistant message to stream into
const assistantId = nextMsgId()
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant', content: '' },
])
try {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
})
if (!response.ok) {
const err = await response.json()
setMessages((prev) => {
const updated = [...prev]
const last = updated[updated.length - 1]
updated[updated.length - 1] = {
...last,
content: `Error: ${(err as { error?: string }).error ?? 'Unknown error'}`,
}
return updated
})
return
}
const reader = (response.body as ReadableStream<Uint8Array>).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.startsWith('data: ') || line === 'data: [DONE]') continue
try {
const chunk = JSON.parse(line.slice(6))
const content = chunk.choices?.[0]?.delta?.content
if (content) {
setMessages((prev) => {
const updated = [...prev]
const last = updated[updated.length - 1]
updated[updated.length - 1] = {
...last,
content: last.content + content,
}
return updated
})
}
} catch {
// Skip malformed chunks
}
}
}
} catch (err) {
setMessages((prev) => {
const updated = [...prev]
const last = updated[updated.length - 1]
updated[updated.length - 1] = {
...last,
content: `Connection error: ${err instanceof Error ? err.message : 'Failed to reach agent'}`,
}
return updated
})
} finally {
setStreaming(false)
inputRef.current?.focus()
}
}
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b pb-4">
<Button variant="ghost" size="icon" className="size-8" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="font-semibold text-lg">{agentName}</h1>
<p className="text-muted-foreground text-xs">
Chat with your OpenClaw agent
</p>
</div>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto py-4">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Send a message to start chatting with your agent.
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={msg.id}
className={cn(
'flex',
msg.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted',
)}
>
<pre className="whitespace-pre-wrap font-sans">{msg.content}</pre>
{msg.role === 'assistant' &&
streaming &&
i === messages.length - 1 &&
!msg.content && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</div>
</div>
))}
</div>
<div className="flex gap-2 border-t pt-4">
<Input
ref={inputRef}
placeholder="Type a message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}}
disabled={streaming}
/>
<Button
size="icon"
onClick={sendMessage}
disabled={!input.trim() || streaming}
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { type FC, useEffect, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface AgentLogsDialogProps {
agentId: string
agentName: string
open: boolean
onOpenChange: (open: boolean) => void
}
export const AgentLogsDialog: FC<AgentLogsDialogProps> = ({
agentId,
agentName,
open,
onOpenChange,
}) => {
const [logs, setLogs] = useState<string[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
const isAtBottom = useRef(true)
useEffect(() => {
if (!open) return
let eventSource: EventSource | null = null
const connect = async () => {
try {
const baseUrl = await getAgentServerUrl()
eventSource = new EventSource(`${baseUrl}/agents/${agentId}/logs`)
eventSource.onmessage = (event) => {
const line = JSON.parse(event.data) as string
setLogs((prev) => [...prev, line])
}
} catch {
// Connection failed
}
}
setLogs([])
connect()
return () => {
eventSource?.close()
}
}, [open, agentId])
// Auto-scroll to bottom when new logs arrive
const logsLength = logs.length
// biome-ignore lint/correctness/useExhaustiveDependencies: logsLength triggers scroll on new log lines
useEffect(() => {
if (isAtBottom.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [logsLength])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Logs: {agentName}</DialogTitle>
</DialogHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[400px] overflow-y-auto rounded-md border bg-black p-4"
>
<pre className="whitespace-pre-wrap font-mono text-green-400 text-xs">
{logs.length === 0 ? 'Waiting for logs...' : logs.join('\n')}
</pre>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,444 @@
import {
AlertCircle,
Cpu,
Loader2,
MessageSquare,
Play,
Plus,
ScrollText,
Square,
Trash2,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { AgentChat } from './AgentChat'
import { AgentLogsDialog } from './AgentLogsDialog'
// Types that use OAuth and have no raw API key to forward
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
interface AgentInstance {
id: string
name: string
status: 'creating' | 'running' | 'stopped' | 'error'
port: number
createdAt: string
error?: string
providerType?: string
}
export const AgentsPage: FC = () => {
const client = useRpcClient()
const { providers, defaultProviderId } = useLlmProviders()
const [agents, setAgents] = useState<AgentInstance[]>([])
const [loading, setLoading] = useState(true)
const [runtimeAvailable, setRuntimeAvailable] = useState<boolean | null>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [newAgentName, setNewAgentName] = useState('')
const [creating, setCreating] = useState(false)
const [actionInProgress, setActionInProgress] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [logsAgentId, setLogsAgentId] = useState<string | null>(null)
const [chatAgentId, setChatAgentId] = useState<string | null>(null)
const [selectedProviderId, setSelectedProviderId] = useState('')
const triggerRefresh = () => setRefreshKey((k) => k + 1)
const logsAgent = logsAgentId
? agents.find((a) => a.id === logsAgentId)
: null
const chatAgent = chatAgentId
? agents.find((a) => a.id === chatAgentId)
: null
// Filter providers that have an API key and can be forwarded to OpenClaw
const compatibleProviders = useMemo(
() => providers.filter((p) => p.apiKey && !OAUTH_ONLY_TYPES.has(p.type)),
[providers],
)
// Pre-select default provider when dialog opens
useEffect(() => {
if (createDialogOpen && compatibleProviders.length > 0) {
const defaultMatch = compatibleProviders.find(
(p) => p.id === defaultProviderId,
)
setSelectedProviderId(defaultMatch?.id ?? compatibleProviders[0].id)
}
}, [createDialogOpen, compatibleProviders, defaultProviderId])
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey triggers refetch from outside the effect
useEffect(() => {
let cancelled = false
const fetchAgents = async () => {
try {
const res = await client.agents.$get()
const data = (await res.json()) as { agents: AgentInstance[] }
if (!cancelled) setAgents(data.agents)
} catch {
// Server may not have the route yet
} finally {
if (!cancelled) setLoading(false)
}
}
const checkRuntime = async () => {
try {
const serverUrl = await getAgentServerUrl()
const res = await fetch(`${serverUrl}/agents/runtime-status`)
const data = await res.json()
if (!cancelled) setRuntimeAvailable(data.available)
} catch {
if (!cancelled) setRuntimeAvailable(false)
}
}
fetchAgents()
checkRuntime()
const interval = setInterval(fetchAgents, 3000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [client, refreshKey])
const handleCreate = async () => {
if (!newAgentName.trim()) return
setCreating(true)
try {
const selectedProvider = compatibleProviders.find(
(p) => p.id === selectedProviderId,
)
const baseUrl = await getAgentServerUrl()
const res = await fetch(`${baseUrl}/agents/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newAgentName.trim(),
providerType: selectedProvider?.type,
apiKey: selectedProvider?.apiKey,
baseUrl: selectedProvider?.baseUrl,
modelId: selectedProvider?.modelId,
providerName: selectedProvider?.name,
}),
})
if (res.ok) {
const data = (await res.json()) as { agent: AgentInstance }
setCreateDialogOpen(false)
setNewAgentName('')
triggerRefresh()
setLogsAgentId(data.agent.id)
}
} finally {
setCreating(false)
}
}
const agentAction = async (
id: string,
action: 'stop' | 'start' | 'delete',
) => {
setActionInProgress(id)
try {
const baseUrl = await getAgentServerUrl()
const method = action === 'delete' ? 'DELETE' : 'POST'
const actionPath =
action === 'delete'
? `${baseUrl}/agents/${id}`
: `${baseUrl}/agents/${id}/${action}`
await fetch(actionPath, { method })
triggerRefresh()
} finally {
setActionInProgress(null)
}
}
const handleStop = (id: string) => agentAction(id, 'stop')
const handleStart = (id: string) => agentAction(id, 'start')
const handleDelete = (id: string) => agentAction(id, 'delete')
const getStatusBadge = (status: AgentInstance['status']) => {
const variants: Record<
string,
{
variant: 'default' | 'secondary' | 'destructive' | 'outline'
label: string
}
> = {
creating: { variant: 'secondary', label: 'Creating...' },
running: { variant: 'default', label: 'Running' },
stopped: { variant: 'outline', label: 'Stopped' },
error: { variant: 'destructive', label: 'Error' },
}
const { variant, label } = variants[status] ?? variants.stopped
return <Badge variant={variant}>{label}</Badge>
}
// Show chat view when an agent is selected
if (chatAgent) {
return (
<AgentChat
agentId={chatAgent.id}
agentName={chatAgent.name}
onBack={() => setChatAgentId(null)}
/>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Agents</h1>
<p className="text-muted-foreground text-sm">
Create and manage OpenClaw agent instances.
</p>
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button disabled={runtimeAvailable === false}>
<Plus className="mr-2 size-4" />
New Agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-name">
Agent Name
</label>
<Input
id="agent-name"
placeholder="e.g. work, personal, research"
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
}}
/>
</div>
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-provider">
LLM Provider
</label>
{compatibleProviders.length > 0 ? (
<>
<Select
value={selectedProviderId}
onValueChange={setSelectedProviderId}
>
<SelectTrigger id="agent-provider">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{compatibleProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} {p.modelId}
{p.id === defaultProviderId ? ' (default)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Uses your existing API key from BrowserOS settings. The
key is passed to the container and never leaves your
machine.
</p>
</>
) : (
<p className="text-muted-foreground text-sm">
No compatible LLM providers configured.{' '}
<a href="#/settings/ai" className="underline">
Add one in AI settings
</a>{' '}
first, or create the agent without one and configure later.
</p>
)}
</div>
<Button
className="w-full"
onClick={handleCreate}
disabled={!newAgentName.trim() || creating}
>
{creating ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Creating...
</>
) : (
'Create Agent'
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{runtimeAvailable === false && (
<Card className="border-destructive/50 bg-destructive/5 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-sm">
Container runtime not available
</p>
<p className="text-muted-foreground text-sm">
Podman is required to run OpenClaw agents. It will be bundled
with BrowserOS in a future update.
</p>
</div>
</div>
</Card>
)}
{agents.length === 0 ? (
<Card className="flex flex-col items-center justify-center p-12 text-center">
<Cpu className="mb-4 size-12 text-muted-foreground/50" />
<h3 className="font-medium text-lg">No agents yet</h3>
<p className="mt-1 max-w-sm text-muted-foreground text-sm">
Create your first OpenClaw agent to get started. Each agent runs in
an isolated container with its own workspace.
</p>
</Card>
) : (
<div className="space-y-3">
{agents.map((agent) => (
<Card key={agent.id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
<Cpu className="size-5 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{agent.name}</span>
{getStatusBadge(agent.status)}
</div>
<p className="text-muted-foreground text-xs">
Port {agent.port}
{agent.status === 'running' && (
<> &middot; Gateway at ws://127.0.0.1:{agent.port}</>
)}
</p>
{agent.error && (
<p className="mt-1 text-destructive text-xs">
{agent.error}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{agent.status === 'running' && (
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setChatAgentId(agent.id)}
title="Chat"
>
<MessageSquare className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setLogsAgentId(agent.id)}
title="View logs"
>
<ScrollText className="size-4" />
</Button>
{agent.status === 'running' && (
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => handleStop(agent.id)}
disabled={actionInProgress === agent.id}
title="Stop"
>
<Square className="size-4" />
</Button>
)}
{agent.status === 'stopped' && (
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => handleStart(agent.id)}
disabled={actionInProgress === agent.id}
title="Start"
>
<Play className="size-4" />
</Button>
)}
{(agent.status === 'stopped' || agent.status === 'error') && (
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(agent.id)}
disabled={actionInProgress === agent.id}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
)}
{actionInProgress === agent.id && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</div>
</div>
</Card>
))}
</div>
)}
{logsAgent && (
<AgentLogsDialog
agentId={logsAgent.id}
agentName={logsAgent.name}
open={!!logsAgentId}
onOpenChange={(isOpen) => {
if (!isOpen) setLogsAgentId(null)
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,798 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Agent management routes for OpenClaw container instances.
* Generates docker-compose.yml and uses Podman compose for lifecycle.
* Manages Podman machine (Linux VM) automatically on macOS/Windows.
* Persists agent metadata to ~/.browseros/agents.json.
*/
import fs from 'node:fs'
import path from 'node:path'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { getBrowserosDir } from '../../lib/browseros-dir'
import { logger } from '../../lib/logger'
import {
getPodmanRuntime,
type PodmanRuntime,
} from '../services/podman-runtime'
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
const MAX_LOG_LINES = 1000
// Maps BrowserOS provider types to OpenClaw environment variable names
const OPENCLAW_PROVIDER_ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
groq: 'GROQ_API_KEY',
mistral: 'MISTRAL_API_KEY',
}
// Persisted to agents.json
interface AgentRecord {
id: string
name: string
status: 'creating' | 'running' | 'stopped' | 'error'
port: number
dir: string
token: string
createdAt: string
error?: string
providerType?: string
}
// Runtime-only (not persisted)
interface AgentRuntime {
logs: string[]
logListeners: Set<(line: string) => void>
}
type AgentInstance = AgentRecord & AgentRuntime
// ─── Persistence ────────────────────────────────────────────────────────────
function getAgentsJsonPath(): string {
return path.join(getBrowserosDir(), 'agents.json')
}
function getAgentsBaseDir(): string {
return path.join(getBrowserosDir(), 'agents')
}
const instances = new Map<string, AgentInstance>()
function saveAgents(): void {
const records: AgentRecord[] = Array.from(instances.values()).map(
({ logs: _, logListeners: __, ...record }) => record,
)
try {
const filePath = getAgentsJsonPath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, JSON.stringify(records, null, 2))
} catch (err) {
logger.warn('Failed to save agents.json', {
error: err instanceof Error ? err.message : String(err),
})
}
}
function loadAgents(): void {
try {
const filePath = getAgentsJsonPath()
if (!fs.existsSync(filePath)) return
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as AgentRecord[]
for (const record of data) {
instances.set(record.id, {
...record,
logs: [],
logListeners: new Set(),
})
}
logger.info(`Loaded ${data.length} agent(s) from agents.json`)
} catch (err) {
logger.warn('Failed to load agents.json', {
error: err instanceof Error ? err.message : String(err),
})
}
}
// Load persisted agents on module init
loadAgents()
// ─── Helpers ────────────────────────────────────────────────────────────────
function pushLog(instance: AgentInstance, line: string) {
const timestamped = `[${new Date().toISOString().slice(11, 19)}] ${line}`
instance.logs.push(timestamped)
if (instance.logs.length > MAX_LOG_LINES) {
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES)
}
for (const listener of instance.logListeners) {
listener(timestamped)
}
}
function updateStatus(
instance: AgentInstance,
status: AgentRecord['status'],
error?: string,
): void {
instance.status = status
instance.error = error
saveAgents()
}
async function isRuntimeAvailable(): Promise<boolean> {
return getPodmanRuntime().isPodmanAvailable()
}
async function findAvailablePort(startPort: number): Promise<number> {
const net = await import('node:net')
return new Promise((resolve) => {
const server = net.createServer()
server.listen(startPort, '127.0.0.1', () => {
server.close(() => resolve(startPort))
})
server.on('error', () => {
resolve(findAvailablePort(startPort + 1))
})
})
}
async function runCommandWithLogs(
instance: AgentInstance,
args: string[],
options?: { cwd?: string; env?: Record<string, string> },
): Promise<number> {
const seen = new Set<string>()
return getPodmanRuntime().runCommand(args, {
cwd: options?.cwd,
env: options?.env,
onOutput: (line) => {
if (!seen.has(line)) {
seen.add(line)
pushLog(instance, line)
}
},
})
}
function composeEnv(name: string): Record<string, string> {
return { COMPOSE_PROJECT_NAME: `browseros-claw-${name}` }
}
function generateComposeFile(config: {
image: string
gatewayPort: number
token: string
configDir: string
workspaceDir: string
}): string {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
return `services:
openclaw-gateway:
image: ${config.image}
ports:
- "127.0.0.1:${config.gatewayPort}:18789"
environment:
- OPENCLAW_GATEWAY_TOKEN=${config.token}
- TZ=${tz}
volumes:
- ${config.configDir}:/home/node/.openclaw
- ${config.workspaceDir}:/home/node/.openclaw/workspace
command: node dist/index.js gateway --bind lan --port 18789
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
`
}
function generateOpenClawConfig(config: {
port: number
providerType?: string
apiKey?: string
baseUrl?: string
modelId?: string
providerName?: string
}): Record<string, unknown> {
const openclawConfig: Record<string, unknown> = {
gateway: {
mode: 'local',
controlUi: {
allowedOrigins: [
`http://127.0.0.1:${config.port}`,
`http://localhost:${config.port}`,
],
},
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
}
if (!config.apiKey || !config.providerType) {
return openclawConfig
}
const directEnvVar = OPENCLAW_PROVIDER_ENV_MAP[config.providerType]
if (directEnvVar) {
// Built-in provider (Anthropic, OpenAI, Google, etc.)
openclawConfig.env = { [directEnvVar]: config.apiKey }
if (config.modelId) {
openclawConfig.agents = {
defaults: {
model: { primary: `${config.providerType}/${config.modelId}` },
},
}
}
} else if (config.baseUrl) {
// Custom OpenAI-compatible provider
const providerId = (config.providerName || 'custom-provider')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const envVarName = `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
openclawConfig.env = { [envVarName]: config.apiKey }
openclawConfig.models = {
mode: 'merge',
providers: {
[providerId]: {
baseUrl: config.baseUrl,
apiKey: `\${${envVarName}}`,
api: 'openai-completions',
...(config.modelId
? { models: [{ id: config.modelId, name: config.modelId }] }
: {}),
},
},
}
if (config.modelId) {
openclawConfig.agents = {
defaults: {
model: { primary: `${providerId}/${config.modelId}` },
},
}
}
}
return openclawConfig
}
async function dumpContainerLogs(
instance: AgentInstance,
agentDir: string,
name: string,
): Promise<void> {
try {
if (fs.existsSync(path.join(agentDir, 'docker-compose.yml'))) {
pushLog(instance, '--- Container logs ---')
await runCommandWithLogs(
instance,
['compose', 'logs', '--no-color', '--tail', '50'],
{ cwd: agentDir, env: composeEnv(name) },
)
pushLog(instance, '--- End container logs ---')
}
} catch {
// Best effort
}
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
/**
* Call on server startup. If agents exist from a previous session,
* pre-start the Podman machine in the background so it's ready
* when the user interacts with agents.
*/
export function initAgentRuntime(): void {
if (instances.size === 0) return
const hasActiveAgents = Array.from(instances.values()).some(
(i) => i.status === 'running' || i.status === 'creating',
)
if (!hasActiveAgents) return
logger.info('Agents exist from previous session, pre-starting Podman machine')
getPodmanRuntime()
.ensureReady()
.then(() => logger.info('Podman machine ready'))
.catch((err) =>
logger.warn('Failed to pre-start Podman machine', {
error: err instanceof Error ? err.message : String(err),
}),
)
}
/**
* Call on server shutdown. Stops all BrowserOS agent containers.
* Only stops the Podman machine if no other containers are running
* (to avoid killing the user's own Podman workloads).
*/
export async function shutdownAgentRuntime(): Promise<void> {
const runtime = getPodmanRuntime()
const available = await runtime.isPodmanAvailable()
if (!available) return
const runningAgents = Array.from(instances.values()).filter(
(i) => i.status === 'running',
)
for (const instance of runningAgents) {
try {
logger.info(`Stopping agent container: ${instance.name}`)
await runtime.runCommand(['compose', 'stop'], {
cwd: instance.dir,
env: composeEnv(instance.name),
})
instance.status = 'stopped'
} catch {
// Best effort — shutting down
}
}
saveAgents()
await stopMachineIfOnlyOurs(runtime)
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* This prevents killing the user's own Podman workloads.
*/
async function stopMachineIfOnlyOurs(runtime: PodmanRuntime): Promise<void> {
const status = await runtime.getMachineStatus()
if (!status.running) return
try {
const proc = Bun.spawn(
[runtime.getPodmanPath(), 'ps', '--format', '{{.Names}}'],
{ stdout: 'pipe', stderr: 'ignore' },
)
const output = await new Response(proc.stdout).text()
await proc.exited
const runningContainers = output
.trim()
.split('\n')
.filter((name) => name.trim())
const allOurs = runningContainers.every((name) =>
name.startsWith('browseros-claw-'),
)
if (runningContainers.length === 0 || allOurs) {
logger.info('No other containers running, stopping Podman machine')
await runtime.stopMachine()
} else {
logger.info('Other containers running, keeping Podman machine alive', {
count: runningContainers.length,
})
}
} catch {
// Best effort — don't stop machine if we can't check
}
}
// ─── Routes ─────────────────────────────────────────────────────────────────
export function createAgentsRoutes() {
return new Hono()
.get('/', (c) => {
const agentList = Array.from(instances.values()).map(
({ logListeners: _, logs: __, ...rest }) => rest,
)
return c.json({ agents: agentList })
})
.get('/runtime-status', async (c) => {
const runtime = getPodmanRuntime()
const available = await runtime.isPodmanAvailable()
const machineStatus = available ? await runtime.getMachineStatus() : null
return c.json({
available,
machineInitialized: machineStatus?.initialized ?? false,
machineRunning: machineStatus?.running ?? false,
needsSetup: available && !machineStatus?.initialized,
})
})
.get('/:id/logs', (c) => {
const { id } = c.req.param()
const instance = instances.get(id)
if (!instance) {
return c.json({ error: 'Agent not found' }, 404)
}
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('Connection', 'keep-alive')
return stream(c, async (s) => {
const write = async (line: string) => {
await s.write(`data: ${JSON.stringify(line)}\n\n`)
}
for (const line of instance.logs) {
await write(line)
}
const onLog = (line: string) => {
write(line).catch(() => {
instance.logListeners.delete(onLog)
})
}
instance.logListeners.add(onLog)
await new Promise<void>((resolve) => {
s.onAbort(() => {
instance.logListeners.delete(onLog)
resolve()
})
})
})
})
.post('/create', async (c) => {
const body = await c.req.json<{
name: string
providerType?: string
apiKey?: string
baseUrl?: string
modelId?: string
providerName?: string
}>()
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
return c.json(
{
error:
'Name must start with a letter or number and contain only letters, numbers, dots, hyphens, and underscores',
},
400,
)
}
const existing = Array.from(instances.values()).find(
(i) => i.name === name,
)
if (existing) {
return c.json({ error: `Agent "${name}" already exists` }, 409)
}
const runtimeAvailable = await isRuntimeAvailable()
if (!runtimeAvailable) {
return c.json(
{
error:
'Podman is not available. Install Podman to create local agents.',
},
503,
)
}
const id = crypto.randomUUID()
const port = await findAvailablePort(18789)
const agentDir = path.join(getAgentsBaseDir(), name)
const token = crypto.randomUUID()
const instance: AgentInstance = {
id,
name,
status: 'creating',
port,
dir: agentDir,
token,
createdAt: new Date().toISOString(),
providerType: body.providerType,
logs: [],
logListeners: new Set(),
}
instances.set(id, instance)
saveAgents()
logger.info('Creating OpenClaw agent instance', {
id,
name,
port,
dir: agentDir,
})
// Set up and start in the background
;(async () => {
try {
const configDir = path.join(agentDir, 'config')
const workspaceDir = path.join(agentDir, 'workspace')
fs.mkdirSync(configDir, { recursive: true })
fs.mkdirSync(workspaceDir, { recursive: true })
pushLog(instance, 'Created agent directories')
// Generate docker-compose.yml
const composeContent = generateComposeFile({
image: OPENCLAW_IMAGE,
gatewayPort: port,
token,
configDir,
workspaceDir,
})
fs.writeFileSync(
path.join(agentDir, 'docker-compose.yml'),
composeContent,
)
pushLog(instance, 'Generated docker-compose.yml')
// Write openclaw.json config (gateway mode, allowed origins, LLM provider, model)
const openclawConfig = generateOpenClawConfig({
port,
providerType: body.providerType,
apiKey: body.apiKey,
baseUrl: body.baseUrl,
modelId: body.modelId,
providerName: body.providerName,
})
fs.writeFileSync(
path.join(configDir, 'openclaw.json'),
JSON.stringify(openclawConfig, null, 2),
)
pushLog(instance, 'Wrote openclaw.json configuration')
pushLog(instance, 'Checking container runtime...')
await getPodmanRuntime().ensureReady((msg) => pushLog(instance, msg))
pushLog(instance, 'Container runtime ready')
pushLog(instance, `Pulling image ${OPENCLAW_IMAGE}...`)
const pullExit = await runCommandWithLogs(
instance,
['compose', 'pull', '--quiet'],
{ cwd: agentDir, env: composeEnv(name) },
)
if (pullExit !== 0) {
throw new Error('Failed to pull OpenClaw image')
}
pushLog(instance, 'Image pulled successfully')
pushLog(instance, 'Starting OpenClaw gateway...')
const upExit = await runCommandWithLogs(
instance,
['compose', 'up', '-d'],
{ cwd: agentDir, env: composeEnv(name) },
)
if (upExit !== 0) {
throw new Error('Failed to start OpenClaw containers')
}
pushLog(instance, 'Waiting for gateway to be ready...')
let healthy = false
for (let i = 0; i < 30; i++) {
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
if (res.ok) {
healthy = true
break
}
} catch {
// Not ready yet
}
await Bun.sleep(1000)
}
if (!healthy) {
await dumpContainerLogs(instance, agentDir, name)
throw new Error('Gateway did not become healthy within 30 seconds')
}
pushLog(
instance,
`OpenClaw gateway is ready at ws://127.0.0.1:${port}`,
)
pushLog(instance, `Control UI available at http://127.0.0.1:${port}`)
updateStatus(instance, 'running')
logger.info('OpenClaw agent instance started', { id, name, port })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
pushLog(instance, `ERROR: ${message}`)
await dumpContainerLogs(instance, agentDir, name)
updateStatus(instance, 'error', message)
logger.error('Failed to create OpenClaw agent instance', {
id,
error: message,
})
}
})()
return c.json(
{
agent: {
id,
name,
status: 'creating',
port,
dir: agentDir,
token,
createdAt: instance.createdAt,
},
},
201,
)
})
.post('/:id/stop', async (c) => {
const { id } = c.req.param()
const instance = instances.get(id)
if (!instance) {
return c.json({ error: 'Agent not found' }, 404)
}
try {
await getPodmanRuntime().ensureReady()
pushLog(instance, 'Stopping agent...')
await runCommandWithLogs(instance, ['compose', 'stop'], {
cwd: instance.dir,
env: composeEnv(instance.name),
})
updateStatus(instance, 'stopped')
pushLog(instance, 'Agent stopped')
return c.json({
agent: {
id,
name: instance.name,
status: instance.status,
port: instance.port,
},
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
pushLog(instance, `ERROR stopping: ${message}`)
return c.json({ error: `Failed to stop agent: ${message}` }, 500)
}
})
.post('/:id/start', async (c) => {
const { id } = c.req.param()
const instance = instances.get(id)
if (!instance) {
return c.json({ error: 'Agent not found' }, 404)
}
try {
pushLog(instance, 'Ensuring container runtime is ready...')
await getPodmanRuntime().ensureReady((msg) => pushLog(instance, msg))
pushLog(instance, 'Starting agent...')
await runCommandWithLogs(instance, ['compose', 'up', '-d'], {
cwd: instance.dir,
env: composeEnv(instance.name),
})
updateStatus(instance, 'running')
pushLog(instance, 'Agent started')
return c.json({
agent: {
id,
name: instance.name,
status: instance.status,
port: instance.port,
},
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
pushLog(instance, `ERROR starting: ${message}`)
return c.json({ error: `Failed to start agent: ${message}` }, 500)
}
})
.post('/:id/chat', async (c) => {
const { id } = c.req.param()
const instance = instances.get(id)
if (!instance) {
return c.json({ error: 'Agent not found' }, 404)
}
if (instance.status !== 'running') {
return c.json({ error: 'Agent is not running' }, 400)
}
const body = await c.req.json<{ message: string }>()
if (!body.message?.trim()) {
return c.json({ error: 'Message is required' }, 400)
}
const openclawUrl = `http://127.0.0.1:${instance.port}/v1/chat/completions`
try {
const response = await fetch(openclawUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${instance.token}`,
},
body: JSON.stringify({
model: 'openclaw/default',
stream: true,
messages: [{ role: 'user', content: body.message.trim() }],
}),
})
if (!response.ok) {
const errText = await response.text()
return c.json(
{ error: `OpenClaw error: ${errText}` },
response.status as 400,
)
}
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
return stream(c, async (s) => {
const reader = (
response.body as ReadableStream<Uint8Array>
).getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(value)
}
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: `Failed to chat: ${message}` }, 500)
}
})
.delete('/:id', async (c) => {
const { id } = c.req.param()
const instance = instances.get(id)
if (!instance) {
return c.json({ error: 'Agent not found' }, 404)
}
try {
await getPodmanRuntime().ensureReady()
for (const listener of instance.logListeners) {
instance.logListeners.delete(listener)
}
await runCommandWithLogs(instance, ['compose', 'down', '-v'], {
cwd: instance.dir,
env: composeEnv(instance.name),
})
fs.rmSync(instance.dir, { recursive: true, force: true })
instances.delete(id)
saveAgents()
// Stop machine if no agents remain and no other containers running
if (instances.size === 0) {
stopMachineIfOnlyOurs(getPodmanRuntime()).catch(() => {})
}
return c.json({ success: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: `Failed to delete agent: ${message}` }, 500)
}
})
}

View File

@@ -20,6 +20,11 @@ import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import {
createAgentsRoutes,
initAgentRuntime,
shutdownAgentRuntime,
} from './routes/agents'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createGraphRoutes } from './routes/graph'
@@ -104,6 +109,7 @@ export async function createHttpServer(config: HttpServerConfig) {
const app = new Hono<Env>()
.use('/*', cors(defaultCorsConfig))
.route('/agents', createAgentsRoutes())
.route('/health', createHealthRoute({ browser }))
.route(
'/shutdown',
@@ -115,6 +121,11 @@ export async function createHttpServer(config: HttpServerConfig) {
error: err instanceof Error ? err.message : String(err),
}),
)
shutdownAgentRuntime().catch((err) =>
logger.warn('Failed to shut down agent runtime', {
error: err instanceof Error ? err.message : String(err),
}),
)
onShutdown?.()
},
}),
@@ -229,6 +240,9 @@ export async function createHttpServer(config: HttpServerConfig) {
logger.info('Consolidated HTTP Server started', { port, host })
// Pre-start Podman machine if agents exist from a previous session
initAgentRuntime()
if (config.aiSdkDevtoolsEnabled) {
logger.info(
'AI SDK DevTools enabled — run `npx @ai-sdk/devtools` to open the viewer',

View File

@@ -0,0 +1,219 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
const isLinux = process.platform === 'linux'
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
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',
})
const code = await proc.exited
return code === 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?: (msg: string) => void): Promise<void> {
if (isLinux) return
const proc = Bun.spawn(
[
this.podmanPath,
'machine',
'init',
'--cpus',
'2',
'--memory',
'2048',
'--disk-size',
'10',
],
{ stdout: 'ignore', stderr: 'pipe' },
)
if (onLog && proc.stderr) {
const reader = proc.stderr.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) onLog(trimmed)
}
}
if (buffer.trim()) onLog(buffer.trim())
}
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine init failed with code ${code}`)
}
async startMachine(onLog?: (msg: string) => void): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
stdout: 'ignore',
stderr: 'pipe',
})
if (onLog && proc.stderr) {
const reader = proc.stderr.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) onLog(trimmed)
}
}
if (buffer.trim()) onLog(buffer.trim())
}
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}`)
this.machineReady = false
}
async ensureReady(onLog?: (msg: string) => void): Promise<void> {
if (this.machineReady) return
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)
}
this.machineReady = true
}
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) {
const streamLines = async (stream: ReadableStream<Uint8Array> | null) => {
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) options.onOutput!(trimmed)
}
}
if (buffer.trim()) options.onOutput!(buffer.trim())
}
await Promise.all([streamLines(proc.stdout), streamLines(proc.stderr)])
}
return proc.exited
}
}
let runtime: PodmanRuntime | null = null
export function getPodmanRuntime(): PodmanRuntime {
if (!runtime) runtime = new PodmanRuntime()
return runtime
}