mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-15 00:49:53 +00:00
Compare commits
16 Commits
fix/patch-
...
feat/claw-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55b61cc60 | ||
|
|
7ae85bb75c | ||
|
|
7e43059c95 | ||
|
|
5c666aa4e0 | ||
|
|
8438325dcb | ||
|
|
928cd46579 | ||
|
|
4e7cfb5998 | ||
|
|
95df5734c6 | ||
|
|
2de20344a0 | ||
|
|
4a571bc750 | ||
|
|
71ba53e528 | ||
|
|
9a77da8d4e | ||
|
|
b1fd3bdd31 | ||
|
|
92560cb369 | ||
|
|
78b797400e | ||
|
|
3852660b52 |
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Brain,
|
||||
CalendarClock,
|
||||
Cpu,
|
||||
Home,
|
||||
PlugZap,
|
||||
Settings,
|
||||
@@ -39,6 +40,7 @@ const primaryNavItems: NavItem[] = [
|
||||
feature: Feature.MANAGED_MCP_SUPPORT,
|
||||
},
|
||||
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
|
||||
{ name: 'Agents', to: '/agents', icon: Cpu },
|
||||
{
|
||||
name: 'Skills',
|
||||
to: '/home/skills',
|
||||
|
||||
@@ -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 { CustomizationPage } from './customization/CustomizationPage'
|
||||
@@ -87,6 +88,7 @@ export const App: FC = () => {
|
||||
{/* Primary nav routes */}
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Settings with dedicated sidebar */}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type AssistantPart =
|
||||
| { kind: 'thinking'; text: string; done: boolean }
|
||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
interface ChatTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function parseSSELines(buffer: string): {
|
||||
events: OpenClawStreamEvent[]
|
||||
remainder: string
|
||||
} {
|
||||
const lines = buffer.split('\n')
|
||||
const remainder = lines.pop() ?? ''
|
||||
const events: OpenClawStreamEvent[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const payload = line.slice(6)
|
||||
if (payload === '[DONE]') continue
|
||||
try {
|
||||
events.push(JSON.parse(payload) as OpenClawStreamEvent)
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return { events, remainder }
|
||||
}
|
||||
|
||||
export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
||||
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [turns])
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStream = async (response: Response) => {
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const { events, remainder } = parseSSELines(buffer)
|
||||
buffer = remainder
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex(
|
||||
(p) => p.kind === 'thinking' && !p.done,
|
||||
)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool: ToolEntry = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running',
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
// Append to last batch if it's consecutive, otherwise start new batch
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const status =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
// Find the batch containing this tool (search from end)
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId
|
||||
? {
|
||||
...t,
|
||||
status: status as ToolEntry['status'],
|
||||
durationMs,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((p) =>
|
||||
p.kind === 'thinking' ? { ...p, done: true } : p,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text,
|
||||
parts: [],
|
||||
done: false,
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(agentId, text, sessionKeyRef.current)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await processStream(response)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h2 className="font-semibold text-lg">{agentName}</h2>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{turns.map((turn) => (
|
||||
<div key={turn.id} className="space-y-3">
|
||||
{/* User message */}
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Assistant response — all parts grouped */}
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={key}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when waiting for first part */}
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
className="min-h-[44px] resize-none"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || streaming}
|
||||
size="icon"
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { AgentChat } from './AgentChat'
|
||||
import {
|
||||
type AgentEntry,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
restartOpenClaw,
|
||||
setupOpenClaw,
|
||||
stopOpenClaw,
|
||||
useOpenClawAgents,
|
||||
useOpenClawStatus,
|
||||
} from './useOpenClaw'
|
||||
|
||||
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
|
||||
|
||||
const StatusBadge: FC<{ status: string }> = ({ status }) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
{
|
||||
variant: 'default' | 'secondary' | 'outline' | 'destructive'
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
running: { variant: 'default', label: 'Running' },
|
||||
starting: { variant: 'secondary', label: 'Starting...' },
|
||||
stopped: { variant: 'outline', label: 'Stopped' },
|
||||
error: { variant: 'destructive', label: 'Error' },
|
||||
uninitialized: { variant: 'outline', label: 'Not Set Up' },
|
||||
}
|
||||
const v = variants[status] ?? { variant: 'outline' as const, label: status }
|
||||
return <Badge variant={v.variant}>{v.label}</Badge>
|
||||
}
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const { status, loading: statusLoading } = useOpenClawStatus()
|
||||
const { providers, defaultProviderId } = useLlmProviders()
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const { agents, loading: agentsLoading } = useOpenClawAgents(refreshKey)
|
||||
|
||||
const [setupOpen, setSetupOpen] = useState(false)
|
||||
const [setupProviderId, setSetupProviderId] = useState('')
|
||||
const [settingUp, setSettingUp] = useState(false)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [createProviderId, setCreateProviderId] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const [actionInProgress, setActionInProgress] = useState(false)
|
||||
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const compatibleProviders = providers.filter(
|
||||
(p) => p.apiKey && !OAUTH_ONLY_TYPES.has(p.type),
|
||||
)
|
||||
|
||||
// Pre-select default provider when dialogs open
|
||||
useEffect(() => {
|
||||
if (compatibleProviders.length === 0) return
|
||||
const fallbackId =
|
||||
compatibleProviders.find((p) => p.id === defaultProviderId)?.id ??
|
||||
compatibleProviders[0].id
|
||||
if (setupOpen) setSetupProviderId(fallbackId)
|
||||
if (createOpen) setCreateProviderId(fallbackId)
|
||||
}, [setupOpen, createOpen, compatibleProviders, defaultProviderId])
|
||||
|
||||
const refresh = () => setRefreshKey((k) => k + 1)
|
||||
|
||||
const handleSetup = async () => {
|
||||
const provider = compatibleProviders.find((p) => p.id === setupProviderId)
|
||||
setSettingUp(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setupOpenClaw({
|
||||
providerType: provider?.type,
|
||||
apiKey: provider?.apiKey,
|
||||
modelId: provider?.modelId,
|
||||
})
|
||||
setSetupOpen(false)
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSettingUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return
|
||||
const provider = compatibleProviders.find((p) => p.id === createProviderId)
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createAgent({
|
||||
name: newName.trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
providerType: provider?.type,
|
||||
apiKey: provider?.apiKey,
|
||||
modelId: provider?.modelId,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setActionInProgress(true)
|
||||
try {
|
||||
await deleteAgent(id)
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setActionInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
setActionInProgress(true)
|
||||
try {
|
||||
await stopOpenClaw()
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setActionInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = async () => {
|
||||
setActionInProgress(true)
|
||||
try {
|
||||
await restartOpenClaw()
|
||||
refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setActionInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (chatAgent) {
|
||||
return (
|
||||
<AgentChat
|
||||
agentId={chatAgent.agentId}
|
||||
agentName={chatAgent.name}
|
||||
onBack={() => setChatAgent(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-bold text-2xl">Agents</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
OpenClaw agents running in a local container
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status?.status === 'running' && (
|
||||
<>
|
||||
<StatusBadge status="running" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRestart}
|
||||
disabled={actionInProgress}
|
||||
title="Restart gateway"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleStop}
|
||||
disabled={actionInProgress}
|
||||
title="Stop gateway"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<AlertCircle className="size-4 text-destructive" />
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Uninitialized state */}
|
||||
{status?.status === 'uninitialized' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.podmanAvailable
|
||||
? 'Create a local container to run autonomous agents with full tool access.'
|
||||
: 'Podman is required to run OpenClaw agents. Install Podman first.'}
|
||||
</p>
|
||||
</div>
|
||||
{status.podmanAvailable && (
|
||||
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stopped state */}
|
||||
{status?.status === 'stopped' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The OpenClaw gateway is not running.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSetupOpen(true)}>Start Gateway</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{status?.status === 'error' && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<AlertCircle className="size-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Error</h3>
|
||||
<p className="text-muted-foreground text-sm">{status.error}</p>
|
||||
</div>
|
||||
<Button onClick={handleRestart} disabled={actionInProgress}>
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Agent list */}
|
||||
{status?.status === 'running' && (
|
||||
<div className="space-y-3">
|
||||
{agentsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No agents yet. Create one to get started.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<Card key={agent.agentId}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-base">{agent.name}</CardTitle>
|
||||
<p className="font-mono text-muted-foreground text-xs">
|
||||
{agent.workspace}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChatAgent(agent)}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
</Button>
|
||||
{agent.agentId !== 'main' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(agent.agentId)}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup Dialog (with provider selector) */}
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Up OpenClaw</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<ProviderSelector
|
||||
providers={compatibleProviders}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={setupProviderId}
|
||||
onSelect={setSetupProviderId}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSetup}
|
||||
disabled={settingUp}
|
||||
className="w-full"
|
||||
>
|
||||
{settingUp ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Setting up...
|
||||
</>
|
||||
) : (
|
||||
'Set Up & Start'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Agent Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="agent-name"
|
||||
className="mb-1 block font-medium text-sm"
|
||||
>
|
||||
Agent Name
|
||||
</label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="research-agent"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
<ProviderSelector
|
||||
providers={compatibleProviders}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={createProviderId}
|
||||
onSelect={setCreateProviderId}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || creating}
|
||||
className="w-full"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Agent'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
providers: Array<{ id: string; type: string; name: string; modelId: string }>
|
||||
defaultProviderId: string
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}) => {
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm">LLM Provider</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.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="provider-select">
|
||||
LLM Provider
|
||||
</label>
|
||||
<Select value={selectedId} onValueChange={onSelect}>
|
||||
<SelectTrigger id="provider-select">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
export interface AgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface OpenClawStatus {
|
||||
status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error'
|
||||
podmanAvailable: boolean
|
||||
machineReady: boolean
|
||||
port: number | null
|
||||
agentCount: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
async function clawFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const res = await fetch(`${baseUrl}/claw${path}`, init)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function useOpenClawStatus(pollMs = 5000) {
|
||||
const [status, setStatus] = useState<OpenClawStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await clawFetch<OpenClawStatus>('/status')
|
||||
if (active) setStatus(s)
|
||||
} catch {
|
||||
// Server may not be running
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, pollMs)
|
||||
return () => {
|
||||
active = false
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [pollMs])
|
||||
|
||||
return { status, loading }
|
||||
}
|
||||
|
||||
export function useOpenClawAgents(refreshKey: number) {
|
||||
const [agents, setAgents] = useState<AgentEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional refetch trigger
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
clawFetch<{ agents: AgentEntry[] }>('/agents')
|
||||
.then((data) => {
|
||||
if (active) setAgents(data.agents ?? [])
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [refreshKey])
|
||||
|
||||
return { agents, loading }
|
||||
}
|
||||
|
||||
export async function setupOpenClaw(input: {
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}) {
|
||||
return clawFetch<{ status: string; agents: AgentEntry[] }>('/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgent(input: {
|
||||
name: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}) {
|
||||
return clawFetch<{ agent: AgentEntry }>('/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string) {
|
||||
return clawFetch<{ success: boolean }>(`/agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function startOpenClaw() {
|
||||
return clawFetch<{ status: string }>('/start', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function stopOpenClaw() {
|
||||
return clawFetch<{ status: string }>('/stop', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function restartOpenClaw() {
|
||||
return clawFetch<{ status: string }>('/restart', { method: 'POST' })
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
sessionKey?: string,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, sessionKey }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
openclaw-gateway:
|
||||
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
|
||||
ports:
|
||||
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- NODE_ENV=production
|
||||
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
|
||||
- OPENCLAW_GATEWAY_BIND=lan
|
||||
- TZ=${TZ}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
||||
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
|
||||
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
extra_hosts:
|
||||
- "host.containers.internal:host-gateway"
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- gateway
|
||||
- --bind
|
||||
- lan
|
||||
- --port
|
||||
- "18789"
|
||||
- --allow-unconfigured
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
227
packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Normal file
227
packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* HTTP routes for OpenClaw agent management.
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { getOpenClawService } from '../../services/openclaw/openclaw-service'
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
return new Hono()
|
||||
.get('/status', async (c) => {
|
||||
const status = await getOpenClawService().getStatus()
|
||||
return c.json(status)
|
||||
})
|
||||
|
||||
.post('/setup', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
|
||||
try {
|
||||
const logs: string[] = []
|
||||
await getOpenClawService().setup(body, (msg) => logs.push(msg))
|
||||
|
||||
const agents = await getOpenClawService().listAgents()
|
||||
return c.json(
|
||||
{
|
||||
status: 'running',
|
||||
port: 18789,
|
||||
agents: agents.map((a) => ({
|
||||
agentId: a.agentId,
|
||||
name: a.name,
|
||||
status: 'running',
|
||||
})),
|
||||
logs,
|
||||
},
|
||||
201,
|
||||
)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('Podman is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/start', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().start()
|
||||
return c.json({ status: 'running' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/stop', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().stop()
|
||||
return c.json({ status: 'stopped' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/restart', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().restart()
|
||||
return c.json({ status: 'running' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents', async (c) => {
|
||||
try {
|
||||
const agents = await getOpenClawService().listAgents()
|
||||
return c.json({ agents })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/agents', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await getOpenClawService().createAgent({
|
||||
name,
|
||||
providerType: body.providerType,
|
||||
apiKey: body.apiKey,
|
||||
modelId: body.modelId,
|
||||
})
|
||||
return c.json({ agent }, 201)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('already exists')) {
|
||||
return c.json({ error: message }, 409)
|
||||
}
|
||||
if (message.includes('must start with')) {
|
||||
return c.json({ error: message }, 400)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.delete('/agents/:id', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
|
||||
try {
|
||||
await getOpenClawService().removeAgent(id)
|
||||
return c.json({ success: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('not found')) {
|
||||
return c.json({ error: message }, 404)
|
||||
}
|
||||
if (message.includes('Cannot delete')) {
|
||||
return c.json({ error: message }, 400)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/agents/:id/chat', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
return c.json({ error: 'Message is required' }, 400)
|
||||
}
|
||||
|
||||
const sessionKey = body.sessionKey ?? crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const eventStream = getOpenClawService().chatStream(
|
||||
id,
|
||||
sessionKey,
|
||||
body.message,
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Key', sessionKey)
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(
|
||||
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
|
||||
)
|
||||
}
|
||||
await s.write(encoder.encode('data: [DONE]\n\n'))
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/logs', async (c) => {
|
||||
try {
|
||||
const logs = await getOpenClawService().getLogs()
|
||||
return c.json({ logs })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/providers', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
providerType: string
|
||||
apiKey: string
|
||||
modelId?: string
|
||||
}>()
|
||||
|
||||
if (!body.providerType || !body.apiKey) {
|
||||
return c.json({ error: 'providerType and apiKey are required' }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
await getOpenClawService().updateProviderKeys(
|
||||
body.providerType,
|
||||
body.apiKey,
|
||||
)
|
||||
return c.json({
|
||||
status: 'restarting',
|
||||
message: 'Provider updated, restarting gateway',
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createMemoryRoutes } from './routes/memory'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createOpenClawRoutes } from './routes/openclaw'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
import { createRefinePromptRoutes } from './routes/refine-prompt'
|
||||
import { createSdkRoutes } from './routes/sdk'
|
||||
@@ -170,6 +171,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosId,
|
||||
}),
|
||||
)
|
||||
.route('/claw', createOpenClawRoutes())
|
||||
|
||||
// Error handler
|
||||
app.onError((err, c) => {
|
||||
|
||||
@@ -34,6 +34,10 @@ export function getBuiltinSkillsDir(): string {
|
||||
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getServerConfigPath(): string {
|
||||
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { metrics } from './lib/metrics'
|
||||
import { isPortInUseError } from './lib/port-binding'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { getOpenClawService } from './services/openclaw/openclaw-service'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import {
|
||||
startSkillSync,
|
||||
@@ -118,12 +119,23 @@ export class Application {
|
||||
this.logStartupSummary()
|
||||
startSkillSync()
|
||||
|
||||
getOpenClawService(this.config.serverPort)
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
|
||||
metrics.log('http_server.started', { version: VERSION })
|
||||
}
|
||||
|
||||
stop(reason?: string): void {
|
||||
logger.info('Shutting down server...', { reason })
|
||||
stopSkillSync()
|
||||
getOpenClawService()
|
||||
.shutdown()
|
||||
.catch(() => {})
|
||||
removeServerConfigSync()
|
||||
|
||||
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Compose-level abstraction over PodmanRuntime.
|
||||
* Manages a single compose project for the OpenClaw gateway container.
|
||||
*/
|
||||
|
||||
import { copyFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
const COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
const COMPOSE_FILE_NAME = 'docker-compose.yml'
|
||||
const ENV_FILE_NAME = '.env'
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
private podman: PodmanRuntime,
|
||||
private projectDir: string,
|
||||
) {}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
return this.podman.ensureReady(onLog)
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return this.podman.isPodmanAvailable()
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
async composeUp(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['up', '-d'], onLog)
|
||||
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeDown(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['down'], onLog)
|
||||
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeStop(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['stop'], onLog)
|
||||
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeRestart(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['restart'], onLog)
|
||||
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composePull(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['pull', '--quiet'], onLog)
|
||||
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
|
||||
lines.push(line),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
async isHealthy(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(port)) return true
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
|
||||
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
|
||||
}
|
||||
|
||||
async writeEnvFile(content: string): Promise<void> {
|
||||
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
*/
|
||||
async stopMachineIfSafe(): Promise<void> {
|
||||
const status = await this.podman.getMachineStatus()
|
||||
if (!status.running) return
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every((name) =>
|
||||
name.startsWith('browseros-openclaw'),
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
await this.podman.stopMachine()
|
||||
}
|
||||
} catch {
|
||||
// Best effort — don't stop machine if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
const containerName = `${COMPOSE_PROJECT_NAME}-openclaw-gateway-1`
|
||||
return this.podman.runCommand(['exec', containerName, ...command], {
|
||||
onOutput: onLog,
|
||||
})
|
||||
}
|
||||
|
||||
private async compose(args: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.podman.runCommand(['compose', ...args], {
|
||||
cwd: this.projectDir,
|
||||
env: { COMPOSE_PROJECT_NAME },
|
||||
onOutput: onLog,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* WebSocket client for the OpenClaw Gateway protocol.
|
||||
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
|
||||
* identity signing, JSON-RPC over WS, and auto-reconnect.
|
||||
* Used for agent CRUD and health — chat uses HTTP.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { logger } from '../../lib/logger'
|
||||
|
||||
const RPC_TIMEOUT_MS = 15_000
|
||||
const RECONNECT_DELAY_MS = 2_000
|
||||
const MAX_RECONNECT_RETRIES = 5
|
||||
const CONTAINER_HOME = '/home/node/.openclaw'
|
||||
|
||||
const SCOPES = [
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.admin',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
]
|
||||
|
||||
interface DeviceIdentity {
|
||||
deviceId: string
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface WsFrame {
|
||||
type: 'req' | 'res' | 'event'
|
||||
id?: string
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
ok?: boolean
|
||||
payload?: Record<string, unknown>
|
||||
error?: { message: string; code?: string }
|
||||
event?: string
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GatewayAgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── Device Identity Helpers ─────────────────────────────────────────
|
||||
|
||||
function rawPublicKeyFromPem(pem: string): Buffer {
|
||||
const der = Buffer.from(
|
||||
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
|
||||
'base64',
|
||||
)
|
||||
return der.subarray(12)
|
||||
}
|
||||
|
||||
function signChallenge(
|
||||
device: DeviceIdentity,
|
||||
nonce: string,
|
||||
token: string,
|
||||
): { signature: string; signedAt: number; publicKey: string } {
|
||||
const signedAt = Date.now()
|
||||
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
|
||||
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
|
||||
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
|
||||
|
||||
return {
|
||||
signature: sig.toString('base64url'),
|
||||
signedAt,
|
||||
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
|
||||
* paired devices file so the gateway trusts it on next boot.
|
||||
* Must be called before compose up (or requires a restart after).
|
||||
*/
|
||||
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
|
||||
const identityPath = join(openclawDir, 'client-identity.json')
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
// Generate new identity
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
||||
const publicKeyPem = publicKey
|
||||
.export({ type: 'spki', format: 'pem' })
|
||||
.toString()
|
||||
const privateKeyPem = privateKey
|
||||
.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString()
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(publicKeyPem)
|
||||
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
|
||||
|
||||
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
|
||||
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
seedPairedDevice(openclawDir, identity)
|
||||
logger.info('Generated client device identity and pre-seeded pairing')
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
|
||||
const devicesDir = join(openclawDir, 'devices')
|
||||
mkdirSync(devicesDir, { recursive: true })
|
||||
|
||||
const pairedPath = join(devicesDir, 'paired.json')
|
||||
let paired: Record<string, unknown> = {}
|
||||
try {
|
||||
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
|
||||
} catch {
|
||||
// First time
|
||||
}
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
|
||||
paired[identity.deviceId] = {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: rawPub.toString('base64url'),
|
||||
platform: process.platform,
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
roles: ['operator'],
|
||||
scopes: SCOPES,
|
||||
pairedAt: Date.now(),
|
||||
label: 'browseros-server',
|
||||
}
|
||||
|
||||
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Gateway Client ──────────────────────────────────────────────────
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null
|
||||
private _connected = false
|
||||
private pendingRequests = new Map<string, PendingRequest>()
|
||||
private reconnectAttempts = 0
|
||||
private shouldReconnect = true
|
||||
private version: string
|
||||
private device: DeviceIdentity | null = null
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private token: string,
|
||||
openclawDir: string,
|
||||
version = '1.0.0',
|
||||
) {
|
||||
this.version = version
|
||||
try {
|
||||
const identityPath = join(openclawDir, 'client-identity.json')
|
||||
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.warn('Client device identity not found, WS auth may fail')
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `ws://127.0.0.1:${this.port}`
|
||||
this.ws = new WebSocket(url, {
|
||||
headers: { Origin: `http://127.0.0.1:${this.port}` },
|
||||
} as unknown as string[])
|
||||
|
||||
let handshakeComplete = false
|
||||
let connectReqId: string | null = null
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
let frame: WsFrame
|
||||
try {
|
||||
frame = JSON.parse(
|
||||
typeof event.data === 'string'
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer),
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!handshakeComplete) {
|
||||
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
||||
const nonce = (frame.payload as Record<string, unknown>)
|
||||
?.nonce as string
|
||||
connectReqId = globalThis.crypto.randomUUID()
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'cli',
|
||||
version: this.version,
|
||||
platform: process.platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: SCOPES,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.token },
|
||||
locale: 'en-US',
|
||||
userAgent: `browseros-server/${this.version}`,
|
||||
}
|
||||
|
||||
if (this.device && nonce) {
|
||||
const signed = signChallenge(this.device, nonce, this.token)
|
||||
params.device = {
|
||||
id: this.device.deviceId,
|
||||
publicKey: signed.publicKey,
|
||||
signature: signed.signature,
|
||||
signedAt: signed.signedAt,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
this.ws?.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: connectReqId,
|
||||
method: 'connect',
|
||||
params,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && frame.id === connectReqId) {
|
||||
if (frame.ok) {
|
||||
handshakeComplete = true
|
||||
this._connected = true
|
||||
this.reconnectAttempts = 0
|
||||
logger.info('Gateway WS connected')
|
||||
resolve()
|
||||
} else {
|
||||
const msg = frame.error?.message ?? 'Handshake failed'
|
||||
logger.error('Gateway WS handshake rejected', {
|
||||
error: msg,
|
||||
code: frame.error?.code,
|
||||
})
|
||||
reject(new Error(msg))
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && frame.id) {
|
||||
const pending = this.pendingRequests.get(frame.id)
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(frame.id)
|
||||
clearTimeout(pending.timer)
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload)
|
||||
} else {
|
||||
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
if (!handshakeComplete) {
|
||||
reject(
|
||||
new Error(
|
||||
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._connected = false
|
||||
this.rejectAllPending('WebSocket closed')
|
||||
if (handshakeComplete) {
|
||||
logger.info('Gateway WS disconnected')
|
||||
this.tryReconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this._connected = false
|
||||
this.rejectAllPending('Client disconnecting')
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC ──────────────────────────────────────────────────────────────
|
||||
|
||||
async rpc<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
|
||||
const id = globalThis.crypto.randomUUID()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error(`RPC timeout: ${method}`))
|
||||
}, RPC_TIMEOUT_MS)
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
})
|
||||
|
||||
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Agent Methods ────────────────────────────────────────────────────
|
||||
|
||||
async listAgents(): Promise<GatewayAgentEntry[]> {
|
||||
const result = await this.rpc<{
|
||||
agents: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}>
|
||||
}>('agents.list')
|
||||
|
||||
return (result.agents ?? []).map((a) => ({
|
||||
agentId: a.id,
|
||||
name: a.name ?? a.id,
|
||||
workspace: a.workspace,
|
||||
model: a.model,
|
||||
}))
|
||||
}
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}): Promise<GatewayAgentEntry> {
|
||||
const result = await this.rpc<{
|
||||
agentId?: string
|
||||
id?: string
|
||||
name?: string
|
||||
workspace?: string
|
||||
model?: string
|
||||
}>('agents.create', input)
|
||||
|
||||
return {
|
||||
agentId: result.agentId ?? result.id ?? input.name,
|
||||
name: result.name ?? input.name,
|
||||
workspace: result.workspace ?? input.workspace,
|
||||
model: result.model ?? input.model,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.rpc('agents.delete', { id: agentId })
|
||||
}
|
||||
|
||||
// ── Health ───────────────────────────────────────────────────────────
|
||||
|
||||
async getHealth(): Promise<Record<string, unknown>> {
|
||||
return this.rpc('health')
|
||||
}
|
||||
|
||||
// ── Chat Stream ─────────────────────────────────────────────────────
|
||||
|
||||
chatStream(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
|
||||
const ws = this.ws
|
||||
const pendingRequests = this.pendingRequests
|
||||
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
|
||||
const idempotencyKey = globalThis.crypto.randomUUID()
|
||||
const originalOnMessage = ws.onmessage
|
||||
|
||||
const restore = () => {
|
||||
ws.onmessage = originalOnMessage
|
||||
}
|
||||
|
||||
return new ReadableStream<OpenClawStreamEvent>({
|
||||
start: (controller) => {
|
||||
const subscribeId = globalThis.crypto.randomUUID()
|
||||
const agentReqId = globalThis.crypto.randomUUID()
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let frame: WsFrame
|
||||
try {
|
||||
frame = JSON.parse(
|
||||
typeof event.data === 'string'
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer),
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && frame.id) {
|
||||
if (frame.id === subscribeId || frame.id === agentReqId) {
|
||||
if (!frame.ok) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: frame.error?.message ?? 'RPC error',
|
||||
code: frame.error?.code,
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
restore()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(frame.id)
|
||||
if (pending) {
|
||||
pendingRequests.delete(frame.id)
|
||||
clearTimeout(pending.timer)
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload)
|
||||
} else {
|
||||
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const anyFrame = frame as any
|
||||
const eventName = anyFrame.event as string | undefined
|
||||
const payload = anyFrame.payload as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
if (!eventName || !payload) return
|
||||
|
||||
if (eventName === 'agent') {
|
||||
const streamType = payload.stream as string | undefined
|
||||
const data = payload.data as Record<string, unknown> | undefined
|
||||
|
||||
if (streamType === 'assistant' && data?.delta) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: data.delta },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (streamType === 'item' && data) {
|
||||
const phase = data.phase as string | undefined
|
||||
if (phase === 'start') {
|
||||
controller.enqueue({
|
||||
type: 'tool-start',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
toolName: data.name ?? data.title,
|
||||
kind: data.kind,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (phase === 'end') {
|
||||
controller.enqueue({
|
||||
type: 'tool-end',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (streamType === 'lifecycle') {
|
||||
controller.enqueue({
|
||||
type: 'lifecycle',
|
||||
data: { phase: data?.phase ?? payload.phase },
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === 'session.tool') {
|
||||
const toolData =
|
||||
(payload.data as Record<string, unknown>) ?? payload
|
||||
const phase =
|
||||
(toolData.phase as string) ?? (payload.phase as string)
|
||||
if (phase === 'result') {
|
||||
controller.enqueue({
|
||||
type: 'tool-output',
|
||||
data: {
|
||||
toolCallId: toolData.toolCallId,
|
||||
isError: toolData.isError ?? false,
|
||||
meta: toolData.meta,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === 'session.message') {
|
||||
const msg = payload.message as Record<string, unknown> | undefined
|
||||
if (msg?.role === 'assistant') {
|
||||
const content = msg.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
if (content) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'thinking') {
|
||||
const text =
|
||||
(block.thinking as string) ??
|
||||
(block.content as string) ??
|
||||
(block.text as string) ??
|
||||
''
|
||||
if (text) {
|
||||
controller.enqueue({
|
||||
type: 'thinking',
|
||||
data: { text },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === 'chat') {
|
||||
const state = payload.state as string | undefined
|
||||
if (state === 'final') {
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: (payload.text as string) ?? '' },
|
||||
})
|
||||
controller.close()
|
||||
restore()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: subscribeId,
|
||||
method: 'sessions.subscribe',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: agentReqId,
|
||||
method: 'agent',
|
||||
params: {
|
||||
message,
|
||||
sessionKey: fullSessionKey,
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
cancel: () => {
|
||||
restore()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
static agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${CONTAINER_HOME}/workspace`
|
||||
: `${CONTAINER_HOME}/workspace-${name}`
|
||||
}
|
||||
|
||||
private tryReconnect(): void {
|
||||
if (!this.shouldReconnect) return
|
||||
if (this.reconnectAttempts >= MAX_RECONNECT_RETRIES) {
|
||||
logger.warn('Gateway WS max reconnect attempts reached')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
logger.info('Gateway WS reconnecting...', {
|
||||
attempt: this.reconnectAttempts,
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect().catch((err) => {
|
||||
logger.warn('Gateway WS reconnect failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
attempt: this.reconnectAttempts,
|
||||
})
|
||||
})
|
||||
}, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string): void {
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error(reason))
|
||||
this.pendingRequests.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure functions for building OpenClaw bootstrap configuration.
|
||||
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
|
||||
*/
|
||||
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
|
||||
const OPENCLAW_GATEWAY_PORT = 18789
|
||||
const CONTAINER_HOME = '/home/node/.openclaw'
|
||||
|
||||
export const 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',
|
||||
}
|
||||
|
||||
export interface BootstrapConfigInput {
|
||||
gatewayPort: number
|
||||
gatewayToken: string
|
||||
browserosServerPort?: number
|
||||
providerType?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface EnvFileInput {
|
||||
image?: string
|
||||
port?: number
|
||||
token: string
|
||||
configDir: string
|
||||
timezone?: string
|
||||
providerKeys?: Record<string, string>
|
||||
}
|
||||
|
||||
export function buildBootstrapConfig(
|
||||
input: BootstrapConfigInput,
|
||||
): Record<string, unknown> {
|
||||
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
workspace: `${CONTAINER_HOME}/workspace`,
|
||||
timeoutSeconds: 4200,
|
||||
thinkingDefault: 'adaptive',
|
||||
}
|
||||
|
||||
if (input.providerType && input.modelId) {
|
||||
defaults.model = { primary: `${input.providerType}/${input.modelId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
port: input.gatewayPort,
|
||||
bind: 'lan',
|
||||
auth: { mode: 'token', token: input.gatewayToken },
|
||||
reload: { mode: 'restart' },
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
allowedOrigins: [
|
||||
`http://127.0.0.1:${input.gatewayPort}`,
|
||||
`http://localhost:${input.gatewayPort}`,
|
||||
],
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults },
|
||||
tools: {
|
||||
profile: 'full',
|
||||
web: {
|
||||
search: { provider: 'duckduckgo', enabled: true },
|
||||
},
|
||||
exec: {
|
||||
host: 'gateway',
|
||||
security: 'full',
|
||||
ask: 'off',
|
||||
},
|
||||
},
|
||||
cron: { enabled: true },
|
||||
hooks: {
|
||||
internal: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
'boot-md': { enabled: true },
|
||||
'bootstrap-extra-files': { enabled: true },
|
||||
'session-memory': { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
servers: {
|
||||
browseros: {
|
||||
url: `http://host.containers.internal:${serverPort}/mcp`,
|
||||
transport: 'streamable-http',
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: { enabled: false },
|
||||
},
|
||||
skills: {
|
||||
install: { nodeManager: 'bun' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEnvFile(input: EnvFileInput): string {
|
||||
const lines: string[] = [
|
||||
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
|
||||
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
|
||||
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
|
||||
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
|
||||
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
|
||||
]
|
||||
|
||||
if (input.providerKeys) {
|
||||
for (const [key, value] of Object.entries(input.providerKeys)) {
|
||||
lines.push(`${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function resolveProviderKeys(
|
||||
providerType?: string,
|
||||
apiKey?: string,
|
||||
): Record<string, string> {
|
||||
const keys: Record<string, string> = {}
|
||||
if (!providerType || !apiKey) return keys
|
||||
|
||||
const envVar = PROVIDER_ENV_MAP[providerType]
|
||||
if (envVar) {
|
||||
keys[envVar] = apiKey
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Main orchestrator for OpenClaw integration.
|
||||
* Container lifecycle via Podman, agent CRUD via Gateway WS RPC,
|
||||
* chat via HTTP /v1/chat/completions proxy.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import { getOpenClawDir } from '../../lib/browseros-dir'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { ContainerRuntime } from './container-runtime'
|
||||
import {
|
||||
ensureClientIdentity,
|
||||
type GatewayAgentEntry,
|
||||
GatewayClient,
|
||||
type OpenClawStreamEvent,
|
||||
} from './gateway-client'
|
||||
import {
|
||||
buildBootstrapConfig,
|
||||
buildEnvFile,
|
||||
resolveProviderKeys,
|
||||
} from './openclaw-config'
|
||||
import { getPodmanRuntime } from './podman-runtime'
|
||||
|
||||
const COMPOSE_RESOURCE = resolve(
|
||||
import.meta.dir,
|
||||
'../../../resources/openclaw-compose.yml',
|
||||
)
|
||||
const OPENCLAW_CONFIG_FILE = 'openclaw.json'
|
||||
const GATEWAY_PORT = 18789
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
|
||||
export type OpenClawStatus =
|
||||
| 'uninitialized'
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'error'
|
||||
|
||||
export interface OpenClawStatusResponse {
|
||||
status: OpenClawStatus
|
||||
podmanAvailable: boolean
|
||||
machineReady: boolean
|
||||
port: number | null
|
||||
agentCount: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SetupInput {
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export class OpenClawService {
|
||||
private runtime: ContainerRuntime
|
||||
private gateway: GatewayClient | null = null
|
||||
private openclawDir: string
|
||||
private port = GATEWAY_PORT
|
||||
private token: string
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
|
||||
constructor(browserosServerPort?: number) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.token = crypto.randomUUID()
|
||||
this.browserosServerPort = browserosServerPort ?? DEFAULT_PORTS.server
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
|
||||
logProgress('Checking container runtime...')
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
'Podman is not available. Install Podman to use OpenClaw agents.',
|
||||
)
|
||||
}
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
logProgress('Container runtime ready')
|
||||
|
||||
await mkdir(this.openclawDir, { recursive: true })
|
||||
await mkdir(join(this.openclawDir, 'workspace'), { recursive: true })
|
||||
|
||||
logProgress('Copying compose file...')
|
||||
await this.runtime.copyComposeFile(COMPOSE_RESOURCE)
|
||||
|
||||
this.token = crypto.randomUUID()
|
||||
const providerKeys = resolveProviderKeys(input.providerType, input.apiKey)
|
||||
const envContent = buildEnvFile({
|
||||
token: this.token,
|
||||
configDir: this.openclawDir,
|
||||
providerKeys,
|
||||
})
|
||||
await this.runtime.writeEnvFile(envContent)
|
||||
logProgress('Generated .env file')
|
||||
|
||||
const config = buildBootstrapConfig({
|
||||
gatewayPort: this.port,
|
||||
gatewayToken: this.token,
|
||||
browserosServerPort: this.browserosServerPort,
|
||||
providerType: input.providerType,
|
||||
modelId: input.modelId,
|
||||
})
|
||||
await this.writeBootstrapConfig(config)
|
||||
logProgress('Generated openclaw.json')
|
||||
|
||||
logProgress('Pulling OpenClaw image...')
|
||||
await this.runtime.composePull(logProgress)
|
||||
logProgress('Image ready')
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.composeUp(logProgress)
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready within 30 seconds'
|
||||
const logs = await this.runtime.composeLogs()
|
||||
logger.error('Gateway readiness check failed', { logs })
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
// Generate client device identity for WS auth
|
||||
logProgress('Generating client device identity...')
|
||||
ensureClientIdentity(this.openclawDir)
|
||||
|
||||
// Attempt WS connect — this triggers a pending pair request
|
||||
logProgress('Pairing client device...')
|
||||
try {
|
||||
await this.connectGateway()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (
|
||||
!msg.includes('pairing required') &&
|
||||
!msg.includes('signature expired')
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Approve the pending device via the openclaw CLI inside the container
|
||||
await this.approvePendingDevice(logProgress)
|
||||
|
||||
logProgress('Connecting to gateway...')
|
||||
await this.connectGateway()
|
||||
|
||||
// Ensure main agent exists (gateway may auto-create it)
|
||||
const existingAgents = await this.gateway!.listAgents()
|
||||
const hasMain = existingAgents.some((a) => a.agentId === 'main')
|
||||
if (!hasMain) {
|
||||
logProgress('Creating main agent...')
|
||||
const model =
|
||||
input.providerType && input.modelId
|
||||
? `${input.providerType}/${input.modelId}`
|
||||
: undefined
|
||||
await this.gateway!.createAgent({
|
||||
name: 'main',
|
||||
workspace: GatewayClient.agentWorkspace('main'),
|
||||
model,
|
||||
})
|
||||
} else {
|
||||
logProgress('Main agent already exists')
|
||||
}
|
||||
|
||||
this.lastError = null
|
||||
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.port}`)
|
||||
logger.info('OpenClaw setup complete', { port: this.port })
|
||||
}
|
||||
|
||||
async start(onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
|
||||
logProgress('Loading gateway auth token...')
|
||||
await this.loadTokenFromEnv()
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.composeUp(logProgress)
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after start'
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
logProgress('Connecting to gateway...')
|
||||
await this.connectGateway()
|
||||
this.lastError = null
|
||||
logger.info('OpenClaw gateway started', { port: this.port })
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.disconnectGateway()
|
||||
await this.runtime.composeStop()
|
||||
logger.info('OpenClaw container stopped')
|
||||
}
|
||||
|
||||
async restart(onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
|
||||
this.disconnectGateway()
|
||||
logProgress('Loading gateway auth token...')
|
||||
await this.loadTokenFromEnv()
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
await this.runtime.composeRestart(logProgress)
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after restart'
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
logProgress('Connecting to gateway...')
|
||||
await this.connectGateway()
|
||||
this.lastError = null
|
||||
logProgress('Gateway restarted successfully')
|
||||
logger.info('OpenClaw gateway restarted', { port: this.port })
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.disconnectGateway()
|
||||
try {
|
||||
await this.runtime.composeStop()
|
||||
} catch {
|
||||
// Best effort during shutdown
|
||||
}
|
||||
await this.runtime.stopMachineIfSafe()
|
||||
logger.info('OpenClaw shutdown complete')
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
async getStatus(): Promise<OpenClawStatusResponse> {
|
||||
const podmanAvailable = await this.runtime.isPodmanAvailable()
|
||||
if (!podmanAvailable) {
|
||||
return {
|
||||
status: 'uninitialized',
|
||||
podmanAvailable: false,
|
||||
machineReady: false,
|
||||
port: null,
|
||||
agentCount: 0,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const isSetUp = existsSync(join(this.openclawDir, OPENCLAW_CONFIG_FILE))
|
||||
if (!isSetUp) {
|
||||
const machineStatus = await this.runtime.getMachineStatus()
|
||||
return {
|
||||
status: 'uninitialized',
|
||||
podmanAvailable: true,
|
||||
machineReady: machineStatus.running,
|
||||
port: null,
|
||||
agentCount: 0,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const machineStatus = await this.runtime.getMachineStatus()
|
||||
const ready = machineStatus.running
|
||||
? await this.runtime.isReady(this.port)
|
||||
: false
|
||||
|
||||
let agentCount = 0
|
||||
if (ready && this.gateway?.isConnected) {
|
||||
try {
|
||||
const agents = await this.gateway.listAgents()
|
||||
agentCount = agents.length
|
||||
} catch {
|
||||
// WS may be momentarily unavailable
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: ready ? 'running' : this.lastError ? 'error' : 'stopped',
|
||||
podmanAvailable: true,
|
||||
machineReady: machineStatus.running,
|
||||
port: this.port,
|
||||
agentCount,
|
||||
error: this.lastError,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Management (via WS RPC) ───────────────────────────────────
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}): Promise<GatewayAgentEntry> {
|
||||
const { name } = input
|
||||
if (!AGENT_NAME_PATTERN.test(name)) {
|
||||
throw new Error(
|
||||
'Agent name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens',
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug('Creating OpenClaw agent', {
|
||||
name,
|
||||
providerType: input.providerType,
|
||||
hasModel: !!input.modelId,
|
||||
hasApiKey: !!input.apiKey,
|
||||
})
|
||||
this.ensureGatewayConnected()
|
||||
|
||||
let needsRestart = false
|
||||
if (input.providerType && input.apiKey) {
|
||||
needsRestart = await this.mergeProviderKeyIfNew(
|
||||
input.providerType,
|
||||
input.apiKey,
|
||||
)
|
||||
}
|
||||
|
||||
if (needsRestart) {
|
||||
await this.restart()
|
||||
}
|
||||
|
||||
const model =
|
||||
input.providerType && input.modelId
|
||||
? `${input.providerType}/${input.modelId}`
|
||||
: undefined
|
||||
|
||||
const agent = await this.gateway!.createAgent({
|
||||
name,
|
||||
workspace: GatewayClient.agentWorkspace(name),
|
||||
model,
|
||||
})
|
||||
|
||||
logger.info('Agent created via WS RPC', {
|
||||
agentId: agent.agentId,
|
||||
providerType: input.providerType,
|
||||
})
|
||||
return agent
|
||||
}
|
||||
|
||||
async removeAgent(agentId: string): Promise<void> {
|
||||
if (agentId === 'main') {
|
||||
throw new Error('Cannot delete the main agent')
|
||||
}
|
||||
|
||||
this.ensureGatewayConnected()
|
||||
await this.gateway!.deleteAgent(agentId)
|
||||
logger.info('Agent removed via WS RPC', { agentId })
|
||||
}
|
||||
|
||||
async listAgents(): Promise<GatewayAgentEntry[]> {
|
||||
this.ensureGatewayConnected()
|
||||
logger.debug('Listing OpenClaw agents')
|
||||
return this.gateway!.listAgents()
|
||||
}
|
||||
|
||||
// ── Chat Stream (WS) ─────────────────────────────────────────────────
|
||||
|
||||
chatStream(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
this.ensureGatewayConnected()
|
||||
logger.debug('Starting OpenClaw chat stream', { agentId, sessionKey })
|
||||
return this.gateway!.chatStream(agentId, sessionKey, message)
|
||||
}
|
||||
|
||||
// ── Provider Keys ────────────────────────────────────────────────────
|
||||
|
||||
async updateProviderKeys(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
await this.mergeProviderKeyIfNew(providerType, apiKey)
|
||||
await this.restart()
|
||||
logger.info('Provider keys updated', { providerType })
|
||||
}
|
||||
|
||||
// ── Logs ─────────────────────────────────────────────────────────────
|
||||
|
||||
async getLogs(tail = 100): Promise<string[]> {
|
||||
logger.debug('Fetching OpenClaw container logs', { tail })
|
||||
return this.runtime.composeLogs(tail)
|
||||
}
|
||||
|
||||
// ── Auto-start on BrowserOS boot ────────────────────────────────────
|
||||
|
||||
async tryAutoStart(): Promise<void> {
|
||||
const isSetUp = existsSync(join(this.openclawDir, OPENCLAW_CONFIG_FILE))
|
||||
if (!isSetUp) return
|
||||
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) return
|
||||
|
||||
try {
|
||||
await this.loadTokenFromEnv()
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
if (!(await this.runtime.isReady(this.port))) {
|
||||
await this.runtime.composeUp()
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.port,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready) {
|
||||
logger.warn('OpenClaw gateway failed to become ready on auto-start')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await this.connectGatewayWithRetry()
|
||||
logger.info('OpenClaw gateway auto-started')
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the gateway, retrying once after a container restart
|
||||
* if the signature is expired (clock skew from Podman VM sleep).
|
||||
*/
|
||||
private async connectGatewayWithRetry(): Promise<void> {
|
||||
try {
|
||||
await this.connectGateway()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (
|
||||
msg.includes('signature expired') ||
|
||||
msg.includes('pairing required')
|
||||
) {
|
||||
logger.info(
|
||||
'Gateway WS auth failed, restarting container to resync clock...',
|
||||
)
|
||||
await this.runtime.composeRestart()
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.port,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready)
|
||||
throw new Error('Gateway not ready after clock resync restart')
|
||||
|
||||
// Re-approve device if needed (pairing may have been lost)
|
||||
try {
|
||||
await this.connectGateway()
|
||||
} catch (retryErr) {
|
||||
const retryMsg =
|
||||
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
if (retryMsg.includes('pairing required')) {
|
||||
await this.approvePendingDevice((m) =>
|
||||
logger.debug(`Auto-start: ${m}`),
|
||||
)
|
||||
await this.connectGateway()
|
||||
} else {
|
||||
throw retryErr
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approves the latest pending device pair request via the openclaw CLI
|
||||
* running inside the container. This is needed because the gateway requires
|
||||
* Ed25519 device identity and approval before granting operator scopes.
|
||||
*/
|
||||
private async approvePendingDevice(
|
||||
logProgress: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
// List pending devices to get the request ID
|
||||
const output: string[] = []
|
||||
const listCode = await this.runtime.execInContainer(
|
||||
[
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'devices',
|
||||
'list',
|
||||
'--json',
|
||||
'--token',
|
||||
this.token,
|
||||
],
|
||||
(line) => output.push(line),
|
||||
)
|
||||
|
||||
if (listCode !== 0) {
|
||||
throw new Error(`Failed to list pending devices (exit ${listCode})`)
|
||||
}
|
||||
|
||||
const jsonStr = output.join('\n')
|
||||
let data: { pending?: Array<{ requestId: string }> }
|
||||
try {
|
||||
data = JSON.parse(jsonStr)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse device list output: ${jsonStr.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const pending = data.pending
|
||||
if (!pending?.length) {
|
||||
logger.warn('No pending device pair requests found')
|
||||
throw new Error('No pending device pair requests to approve')
|
||||
}
|
||||
|
||||
const requestId = pending[0].requestId
|
||||
logProgress(`Approving device pair request ${requestId.slice(0, 8)}...`)
|
||||
|
||||
const code = await this.runtime.execInContainer([
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'devices',
|
||||
'approve',
|
||||
requestId,
|
||||
'--token',
|
||||
this.token,
|
||||
'--json',
|
||||
])
|
||||
|
||||
if (code !== 0) {
|
||||
logger.warn('Device approval command exited with code', { code })
|
||||
throw new Error('Failed to approve client device pairing')
|
||||
}
|
||||
|
||||
logProgress('Client device approved')
|
||||
}
|
||||
|
||||
private async connectGateway(): Promise<void> {
|
||||
this.disconnectGateway()
|
||||
logger.debug('Connecting OpenClaw gateway client', { port: this.port })
|
||||
this.gateway = new GatewayClient(this.port, this.token, this.openclawDir)
|
||||
await this.gateway.connect()
|
||||
}
|
||||
|
||||
private disconnectGateway(): void {
|
||||
if (this.gateway) {
|
||||
this.gateway.disconnect()
|
||||
this.gateway = null
|
||||
}
|
||||
}
|
||||
|
||||
private ensureGatewayConnected(): void {
|
||||
if (!this.gateway?.isConnected) {
|
||||
logger.debug('OpenClaw gateway client is not connected')
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
}
|
||||
|
||||
private async writeBootstrapConfig(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const configPath = join(this.openclawDir, OPENCLAW_CONFIG_FILE)
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a provider API key into .env. Returns true if the key was NEW
|
||||
* (not previously present), meaning a container restart is needed to
|
||||
* pick up the new env var.
|
||||
*/
|
||||
private async mergeProviderKeyIfNew(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
): Promise<boolean> {
|
||||
const newKeys = resolveProviderKeys(providerType, apiKey)
|
||||
if (Object.keys(newKeys).length === 0) return false
|
||||
|
||||
const envPath = join(this.openclawDir, '.env')
|
||||
let content = ''
|
||||
try {
|
||||
content = await readFile(envPath, 'utf-8')
|
||||
} catch {
|
||||
// .env may not exist yet
|
||||
}
|
||||
|
||||
let addedNew = false
|
||||
let updatedExisting = false
|
||||
for (const [key, value] of Object.entries(newKeys)) {
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm')
|
||||
if (pattern.test(content)) {
|
||||
content = content.replace(pattern, `${key}=${value}`)
|
||||
updatedExisting = true
|
||||
} else {
|
||||
content = `${content.trimEnd()}\n${key}=${value}\n`
|
||||
addedNew = true
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(envPath, content, { mode: 0o600 })
|
||||
logger.debug('Updated OpenClaw provider credentials', {
|
||||
providerType,
|
||||
addedNew,
|
||||
updatedExisting,
|
||||
})
|
||||
return addedNew
|
||||
}
|
||||
|
||||
private async loadTokenFromEnv(): Promise<void> {
|
||||
const envPath = join(this.openclawDir, '.env')
|
||||
try {
|
||||
const content = await readFile(envPath, 'utf-8')
|
||||
const match = content.match(/^OPENCLAW_GATEWAY_TOKEN=(.+)$/m)
|
||||
if (match) {
|
||||
this.token = match[1]
|
||||
logger.debug('Loaded OpenClaw gateway token from env')
|
||||
}
|
||||
} catch {
|
||||
logger.debug('OpenClaw env file not available while loading token')
|
||||
}
|
||||
}
|
||||
|
||||
private createProgressLogger(
|
||||
onLog?: (msg: string) => void,
|
||||
): (msg: string) => void {
|
||||
return (msg) => {
|
||||
logger.debug(`OpenClaw: ${msg}`)
|
||||
onLog?.(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let service: OpenClawService | null = null
|
||||
|
||||
export function getOpenClawService(
|
||||
browserosServerPort?: number,
|
||||
): OpenClawService {
|
||||
if (!service) service = new OpenClawService(browserosServerPort)
|
||||
return service
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstraction over the Podman CLI for container lifecycle management.
|
||||
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
|
||||
* On Linux, machine operations are no-ops since Podman runs natively.
|
||||
*/
|
||||
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
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',
|
||||
})
|
||||
return (await proc.exited) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
if (isLinux) return { initialized: true, running: true }
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'machine', 'list', '--format', 'json'],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
)
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
const machines = JSON.parse(output) as Array<{
|
||||
Running?: boolean
|
||||
LastUp?: string
|
||||
}>
|
||||
|
||||
if (!machines.length) return { initialized: false, running: false }
|
||||
|
||||
const machine = machines[0]
|
||||
const running =
|
||||
machine.Running === true || machine.LastUp === 'Currently running'
|
||||
|
||||
return { initialized: true, running }
|
||||
} catch {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
}
|
||||
|
||||
async initMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.podmanPath,
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'--memory',
|
||||
'2048',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine init failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async stopMachine(): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
this.machineReady = false
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): 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) {
|
||||
await Promise.all([
|
||||
this.drainStream(proc.stdout ?? null, options.onOutput),
|
||||
this.drainStream(proc.stderr ?? null, options.onOutput),
|
||||
])
|
||||
}
|
||||
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists running container names. Used to check whether non-BrowserOS
|
||||
* containers are running before stopping the Podman machine.
|
||||
*/
|
||||
async listRunningContainers(): Promise<string[]> {
|
||||
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((name) => name.trim())
|
||||
}
|
||||
|
||||
private async drainStderr(
|
||||
proc: {
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
},
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
if (!onLog || !proc.stderr) return
|
||||
await this.drainStream(proc.stderr, onLog)
|
||||
}
|
||||
|
||||
private async drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PodmanRuntime | null = null
|
||||
|
||||
export function getPodmanRuntime(): PodmanRuntime {
|
||||
if (!runtime) runtime = new PodmanRuntime()
|
||||
return runtime
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const PATHS = {
|
||||
SKILLS_DIR_NAME: 'skills',
|
||||
BUILTIN_DIR_NAME: 'builtin',
|
||||
SERVER_CONFIG_FILE_NAME: 'server.json',
|
||||
OPENCLAW_DIR_NAME: 'openclaw',
|
||||
SOUL_MAX_LINES: 150,
|
||||
MEMORY_RETENTION_DAYS: 30,
|
||||
SESSION_RETENTION_DAYS: 30,
|
||||
|
||||
Reference in New Issue
Block a user