Compare commits

...

18 Commits

Author SHA1 Message Date
Dani Akash
4c2bb30f29 fix: create .openclaw directory via docker exec before injecting config
The named volume starts with only default home files (.bashrc, .profile).
OpenClaw's gateway crashes immediately without creating ~/.openclaw.
Now explicitly creates the directory tree via docker exec mkdir -p
before running docker cp to inject config files.

Flow: up -d -> sleep 1s -> exec mkdir -p -> docker cp files -> chown -> restart
2026-04-02 20:30:13 +05:30
Dani Akash
6acda41eff chore: add debug logging to diagnose docker cp volume injection 2026-04-02 20:27:31 +05:30
Dani Akash
f63de331f0 fix: start container first to init volume, then inject config and restart
The named volume starts empty. Docker cp to specific paths fails because
the directory tree doesn't exist yet. New approach:
1. docker compose up -d (starts container, it crashes due to missing config,
   but the OpenClaw image's entrypoint creates /home/node/.openclaw)
2. Wait 2s for volume initialization
3. docker cp individual files into the now-existing directories
4. chown -R node:node /home/node/.openclaw (docker cp creates as root)
5. docker compose restart (gateway picks up the new config)
2026-04-02 20:21:34 +05:30
Dani Akash
d305cf217b fix: build directory tree locally before docker cp into named volume
The named volume starts empty — /home/node/.openclaw doesn't exist
in a stopped container. Instead of copying individual files to specific
paths (which fails), build the full directory tree locally in a temp
dir (home/node/.openclaw/openclaw.json, workspace/SOUL.md, etc.) and
copy the entire tree in one docker cp command.
2026-04-02 20:18:36 +05:30
Dani Akash
7b2b947399 feat: secure persistence with named volumes, workspace, and SOUL.md
Security:
- Replace bind mounts with named Docker volume for /home/node
- Only user-chosen workspace directory bind-mounted at /workspace
- Config injected via docker cp, temp files deleted immediately

Workspace:
- Users choose output folder during agent creation
- Default: ~/Documents/BrowserOS Agents/<name>/
- Agent writes deliverables to /workspace, visible on host

Agent Environment Awareness:
- Pre-write SOUL.md and AGENTS.md into container via docker cp
- SOUL.md defines identity, environment, boundaries
- AGENTS.md provides tool guidance and output instructions

Creation flow: generate files -> pull -> create (stopped) -> docker cp
config into named volume -> delete temp files -> start -> health check
2026-04-02 20:15:07 +05:30
Dani Akash
b8be74069d feat: auto-configure BrowserOS MCP for new OpenClaw agents
- Add extra_hosts (host.docker.internal:host-gateway) to generated
  docker-compose.yml so the container can reach the host machine
- Add mcp.servers.browseros to generated openclaw.json pointing to
  http://host.docker.internal:<port>/mcp with streamable-http transport
- Pass BrowserOS server port through createAgentsRoutes -> generateOpenClawConfig
- New OpenClaw agents automatically discover BrowserOS's 53 browser
  tools and 40+ app integrations with zero user configuration
2026-04-02 17:51:26 +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
7 changed files with 1712 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,520 @@
import {
AlertCircle,
Cpu,
Folder,
FolderOpen,
Loader2,
MessageSquare,
Play,
Plus,
ScrollText,
Square,
Trash2,
X,
} 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 { getBrowserOSAdapter } from '@/lib/browseros/adapter'
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
workspacePath?: string
}
export const AgentsPage: FC = () => {
const client = useRpcClient()
const { providers, defaultProviderId } = useLlmProviders()
const [agents, setAgents] = useState<AgentInstance[]>([])
const [loading, setLoading] = useState(true)
const [dockerAvailable, setDockerAvailable] = 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 [workspacePath, setWorkspacePath] = useState<{
name: string
path: string
} | null>(null)
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 checkDocker = async () => {
try {
const res = await client.agents['docker-status'].$get()
const data = (await res.json()) as { available: boolean }
if (!cancelled) setDockerAvailable(data.available)
} catch {
if (!cancelled) setDockerAvailable(false)
}
}
fetchAgents()
checkDocker()
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,
workspacePath: workspacePath?.path,
}),
})
if (res.ok) {
const data = (await res.json()) as { agent: AgentInstance }
setCreateDialogOpen(false)
setNewAgentName('')
setWorkspacePath(null)
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 running in Docker
containers.
</p>
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button disabled={dockerAvailable === 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>
<div className="space-y-2">
<span className="font-medium text-sm">Output Workspace</span>
{workspacePath ? (
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">
{workspacePath.path}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={() => setWorkspacePath(null)}
>
<X className="size-3" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full"
onClick={async () => {
try {
const adapter = getBrowserOSAdapter()
const result = await adapter.choosePath({
type: 'folder',
})
if (result) {
setWorkspacePath({
name: result.name,
path: result.path,
})
}
} catch {
// User cancelled or API not available
}
}}
>
<FolderOpen className="mr-2 size-4" />
Choose output folder
</Button>
)}
<p className="text-muted-foreground text-xs">
The agent saves reports and files here. This is the only
folder the agent can access on your computer.
{!workspacePath &&
' If not chosen, a default folder will be created.'}
</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>
{dockerAvailable === 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">Docker is not available</p>
<p className="text-muted-foreground text-sm">
Install{' '}
<a
href="https://www.docker.com/products/docker-desktop/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Docker Desktop
</a>{' '}
or{' '}
<a
href="https://orbstack.dev"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
OrbStack
</a>{' '}
to create local OpenClaw agents.
</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 Docker 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.workspacePath && (
<> &middot; {agent.workspacePath}</>
)}
</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,884 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Agent management routes for OpenClaw container instances.
* Uses named Docker volumes for secure internal persistence.
* User workspace is the only host directory exposed to the container.
* Provides SSE log streaming for real-time setup visibility.
* Persists agent metadata to ~/.browseros/agents.json.
*/
import fs from 'node:fs'
import os from 'node:os'
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'
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
workspacePath: 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')
}
function getDefaultWorkspacePath(agentName: string): string {
return path.join(os.homedir(), 'Documents', 'BrowserOS Agents', agentName)
}
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),
})
}
}
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 isDockerAvailable(): Promise<boolean> {
try {
const proc = Bun.spawn(['docker', 'info'], {
stdout: 'pipe',
stderr: 'pipe',
})
const exitCode = await proc.exited
return exitCode === 0
} catch {
return false
}
}
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 streamProcessOutput(
proc: ReturnType<typeof Bun.spawn>,
instance: AgentInstance,
prefix: string,
): Promise<void> {
const seen = new Set<string>()
const dedup =
(tag: string) => async (readable: ReadableStream<Uint8Array>) => {
const reader = readable.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
pushLog(instance, `[${tag}] ${trimmed}`)
}
}
}
const trimmed = buf.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
pushLog(instance, `[${tag}] ${trimmed}`)
}
}
await Promise.all([
dedup(prefix)(proc.stdout as ReadableStream<Uint8Array>),
dedup(prefix)(proc.stderr as ReadableStream<Uint8Array>),
])
}
async function runCommandWithLogs(
instance: AgentInstance,
cmd: string,
args: string[],
options?: { cwd?: string; env?: Record<string, string> },
): Promise<number> {
const proc = Bun.spawn([cmd, ...args], {
stdout: 'pipe',
stderr: 'pipe',
cwd: options?.cwd,
env: { ...process.env, ...options?.env },
})
await streamProcessOutput(proc, instance, cmd)
return proc.exited
}
function composeEnv(name: string): Record<string, string> {
return { COMPOSE_PROJECT_NAME: `browseros-claw-${name}` }
}
// ─── Generators ─────────────────────────────────────────────────────────────
function generateComposeFile(config: {
image: string
gatewayPort: number
token: string
homeVolumeName: string
workspacePath: 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}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ${config.homeVolumeName}:/home/node
- ${config.workspacePath}:/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
volumes:
${config.homeVolumeName}:
`
}
function generateOpenClawConfig(config: {
port: number
browserosServerPort: 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 },
},
},
},
mcp: {
servers: {
browseros: {
url: `http://host.docker.internal:${config.browserosServerPort}/mcp`,
transport: 'streamable-http',
},
},
},
}
if (!config.apiKey || !config.providerType) {
return openclawConfig
}
const directEnvVar = OPENCLAW_PROVIDER_ENV_MAP[config.providerType]
if (directEnvVar) {
openclawConfig.env = { [directEnvVar]: config.apiKey }
if (config.modelId) {
openclawConfig.agents = {
defaults: {
model: { primary: `${config.providerType}/${config.modelId}` },
},
}
}
} else if (config.baseUrl) {
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
}
function generateSoulMd(): string {
const lines = [
'# SOUL.md',
'',
'You are an AI assistant running inside BrowserOS.',
'',
'## Core Truths',
'- **Results Over Process** — Do not explain what you are going to do. Just do it.',
'- **Ownership** — When you take on a task, you own it end-to-end.',
'- **Output Goes to /workspace** — All reports, documents, and files you create for the user MUST be saved to /workspace. This is the only directory the user can see from their computer.',
'',
'## Environment',
'- You are running inside a Docker container managed by BrowserOS',
'- Your internal workspace is at ~/.openclaw/workspace (for your own notes and memory)',
'- The output directory is mounted at /workspace — save all deliverables here',
'- You have access to BrowserOS MCP tools for web browsing, taking screenshots, filling forms, and accessing 40+ connected apps (Gmail, Slack, Notion, GitHub, etc.)',
'',
'## Boundaries',
'- Do NOT attempt to access the host filesystem outside /workspace',
'- Do NOT modify your own configuration files',
'- Save internal notes to ~/.openclaw/workspace/memory/ (your private memory)',
'- Save user-facing output to /workspace (their computer)',
'',
'## Vibe',
'- Direct, concise, helpful',
'- When asked to produce a report or document, write it to /workspace and tell the user the filename',
'- When you need web data or app data, use the BrowserOS MCP tools',
'',
]
return lines.join('\n')
}
function generateAgentsMd(): string {
const lines = [
'# AGENTS.md',
'',
'## Output Directory',
'All files intended for the user (reports, documents, exports, analysis) must be written to:',
'```',
'/workspace/',
'```',
'This directory is visible on the user host computer. Files saved here appear immediately in their chosen folder.',
'',
'## Internal Directory',
'Your internal working files (memory, notes, drafts) go to:',
'```',
'~/.openclaw/workspace/',
'```',
'This is your private workspace inside the container.',
'',
'## Available Tools',
'',
'### BrowserOS MCP (web and apps)',
'You have access to BrowserOS tools via MCP:',
'- **Browser automation**: navigate pages, click, fill forms, take screenshots, extract content',
'- **Connected apps**: Gmail, Slack, Notion, GitHub, Linear, Google Docs, and 40+ more',
'- Use these tools when you need data from the web or from the user connected applications',
'',
'### File System',
'- Read and write files in /workspace (user output) and ~/.openclaw/workspace (internal)',
'- Install tools via apt or npm if needed (persists across restarts)',
'',
'### Memory',
'- Save important facts to MEMORY.md (loaded every session)',
'- Save daily context to memory/YYYY-MM-DD.md',
'',
]
return lines.join('\n')
}
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,
'docker',
['compose', 'logs', '--no-color', '--tail', '50'],
{ cwd: agentDir, env: composeEnv(name) },
)
pushLog(instance, '--- End container logs ---')
}
} catch {
// Best effort
}
}
// ─── Routes ─────────────────────────────────────────────────────────────────
export function createAgentsRoutes(config: { serverPort: number }) {
return new Hono()
.get('/', (c) => {
const agentList = Array.from(instances.values()).map(
({ logListeners: _, logs: __, ...rest }) => rest,
)
return c.json({ agents: agentList })
})
.get('/docker-status', async (c) => {
const available = await isDockerAvailable()
return c.json({ available })
})
.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
workspacePath?: 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 dockerAvailable = await isDockerAvailable()
if (!dockerAvailable) {
return c.json(
{
error:
'Docker is not available. Install Docker Desktop or OrbStack 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 homeVolumeName = `browseros-claw-${name}-home`
const workspacePath =
body.workspacePath?.trim() || getDefaultWorkspacePath(name)
const instance: AgentInstance = {
id,
name,
status: 'creating',
port,
dir: agentDir,
token,
createdAt: new Date().toISOString(),
providerType: body.providerType,
workspacePath,
logs: [],
logListeners: new Set(),
}
instances.set(id, instance)
saveAgents()
logger.info('Creating OpenClaw agent instance', {
id,
name,
port,
dir: agentDir,
workspacePath,
})
// Set up and start in the background
;(async () => {
try {
// Create agent dir and workspace on host
fs.mkdirSync(agentDir, { recursive: true })
fs.mkdirSync(workspacePath, { recursive: true })
pushLog(instance, 'Created agent directories')
// Generate docker-compose.yml (named volume + workspace bind mount)
const composeContent = generateComposeFile({
image: OPENCLAW_IMAGE,
gatewayPort: port,
token,
homeVolumeName,
workspacePath,
})
fs.writeFileSync(
path.join(agentDir, 'docker-compose.yml'),
composeContent,
)
pushLog(instance, 'Generated docker-compose.yml')
// Generate config files to inject via docker cp
const tmpDir = path.join(agentDir, '.tmp')
fs.mkdirSync(tmpDir, { recursive: true })
const openclawConfig = generateOpenClawConfig({
port,
browserosServerPort: config.serverPort,
providerType: body.providerType,
apiKey: body.apiKey,
baseUrl: body.baseUrl,
modelId: body.modelId,
providerName: body.providerName,
})
fs.writeFileSync(
path.join(tmpDir, 'openclaw.json'),
JSON.stringify(openclawConfig, null, 2),
)
fs.writeFileSync(path.join(tmpDir, 'SOUL.md'), generateSoulMd())
fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), generateAgentsMd())
pushLog(instance, 'Generated configuration files')
// Pull image
pushLog(instance, `Pulling image ${OPENCLAW_IMAGE}...`)
const pullExit = await runCommandWithLogs(
instance,
'docker',
['compose', 'pull', '--quiet'],
{ cwd: agentDir, env: composeEnv(name) },
)
if (pullExit !== 0) {
throw new Error('Failed to pull OpenClaw image')
}
pushLog(instance, 'Image pulled successfully')
// Start the container (gateway will crash — no config yet, but
// we need the container running so docker exec works)
pushLog(instance, 'Initializing container...')
await runCommandWithLogs(
instance,
'docker',
['compose', 'up', '-d'],
{ cwd: agentDir, env: composeEnv(name) },
)
await Bun.sleep(1000)
const containerName = `browseros-claw-${name}-openclaw-gateway-1`
// Create the directory structure inside the named volume
// (OpenClaw doesn't create ~/.openclaw on its own when it crashes)
pushLog(instance, 'Creating config directories...')
await runCommandWithLogs(instance, 'docker', [
'exec',
containerName,
'mkdir',
'-p',
'/home/node/.openclaw/workspace',
])
// Copy config files into the container
pushLog(instance, 'Injecting configuration...')
for (const [src, dest] of [
['openclaw.json', '/home/node/.openclaw/openclaw.json'],
['SOUL.md', '/home/node/.openclaw/workspace/SOUL.md'],
['AGENTS.md', '/home/node/.openclaw/workspace/AGENTS.md'],
]) {
const cpExit = await runCommandWithLogs(instance, 'docker', [
'cp',
path.join(tmpDir, src),
`${containerName}:${dest}`,
])
if (cpExit !== 0) {
throw new Error(`Failed to inject ${src}`)
}
}
// Fix ownership — docker cp creates files as root
await runCommandWithLogs(instance, 'docker', [
'exec',
containerName,
'chown',
'-R',
'node:node',
'/home/node/.openclaw',
])
// Clean up temp files
fs.rmSync(tmpDir, { recursive: true, force: true })
pushLog(instance, 'Configuration injected')
// Restart so the gateway picks up the new config
pushLog(instance, 'Starting OpenClaw gateway...')
await runCommandWithLogs(instance, 'docker', ['compose', 'restart'], {
cwd: agentDir,
env: composeEnv(name),
})
// Wait for health check
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}`)
pushLog(instance, `Output workspace: ${workspacePath}`)
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,
workspacePath,
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 {
pushLog(instance, 'Stopping agent...')
await runCommandWithLogs(instance, 'docker', ['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, 'Starting agent...')
await runCommandWithLogs(instance, 'docker', ['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 {
for (const listener of instance.logListeners) {
instance.logListeners.delete(listener)
}
// down -v removes the named volume too
await runCommandWithLogs(
instance,
'docker',
['compose', 'down', '-v'],
{ cwd: instance.dir, env: composeEnv(instance.name) },
)
// Remove compose file dir (NOT the user's workspace)
fs.rmSync(instance.dir, { recursive: true, force: true })
instances.delete(id)
saveAgents()
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,7 @@ import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import { createAgentsRoutes } from './routes/agents'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createGraphRoutes } from './routes/graph'
@@ -104,6 +105,7 @@ export async function createHttpServer(config: HttpServerConfig) {
const app = new Hono<Env>()
.use('/*', cors(defaultCorsConfig))
.route('/agents', createAgentsRoutes({ serverPort: port }))
.route('/health', createHealthRoute({ browser }))
.route(
'/shutdown',