Compare commits

...

16 Commits

Author SHA1 Message Date
shivammittal274
d55b61cc60 feat(openclaw): WS streaming, device auth, MCP port fix (#687)
* feat(openclaw): WS streaming, device auth, MCP port fix

- Fix GatewayClient WS handshake: add Ed25519 device identity signing,
  Origin header, mode: cli (mode: ui requires device identity always)
- Add auto device pairing flow: generate client identity, attempt WS
  connect (triggers pending), approve via openclaw CLI, reconnect
- Replace HTTP /v1/chat/completions proxy with WS-based streaming that
  surfaces tool calls, thinking blocks, and text deltas
- Add chatStream() to GatewayClient returning ReadableStream of typed
  OpenClawStreamEvent (text-delta, thinking, tool-start/end, lifecycle)
- Update chat route to stream WS events as SSE to the extension
- Pass actual server port to OpenClaw config (fixes MCP bridge in dev)
- Rewrite AgentChat.tsx with turn-based model using Message/MessageContent
  components matching sidepanel pattern, with tool batching logic that
  groups consecutive tools and breaks on text/thinking (same as sidepanel)
- Add execInContainer() to ContainerRuntime for CLI commands
- Fix gateway response field mapping (id→agentId, agents.list/create)
- Skip creating main agent if gateway auto-creates it

* fix(openclaw): retry WS connect on signature expired (Podman clock skew)

Podman VM clock drifts when Mac sleeps, causing Ed25519 signature
validation to fail with "device signature expired" on auto-start.
Add connectGatewayWithRetry() that restarts the container (resyncs
clock) and re-approves the device if needed.

* fix(openclaw): address PR review — stream cleanup, error handling

- Fix silent catch in setup(): only swallow "pairing required" and
  "signature expired" errors, re-throw everything else
- Guard JSON.parse in approvePendingDevice(): check exit code and
  wrap parse in try/catch with descriptive error messages
- Add try/finally in chat SSE route: reader.cancel() on disconnect
- Add cancel callback to chatStream ReadableStream: restores
  ws.onmessage when stream is cancelled (prevents handler leak)
2026-04-13 08:50:58 -07:00
Nikhil Sonti
7ae85bb75c fix(openclaw): log service progress through server logger 2026-04-10 15:02:22 -07:00
Nikhil Sonti
7e43059c95 fix(openclaw): use agentId field in setup response mapping
Fix type error: GatewayAgentEntry uses agentId not id.
2026-04-10 14:55:48 -07:00
Nikhil Sonti
5c666aa4e0 refactor(openclaw): agent CRUD via WS RPC, per-agent chat targeting
Replace JSON mutation + restart with GatewayClient WS RPC calls for
agents.create, agents.delete, agents.list. Chat proxy now uses
model: "openclaw/<agentId>" for per-agent targeting. Setup writes
bootstrap config once then creates "main" agent via WS after gateway
starts. Container restarts only when a new provider env var is added.
2026-04-10 14:55:09 -07:00
Nikhil Sonti
8438325dcb refactor(openclaw): simplify config to bootstrap-only, add /readyz health
Config no longer contains agents.list — agent CRUD is handled via WS RPC.
buildOpenClawConfig → buildBootstrapConfig, removed makeAgentEntry and
AgentEntry (agents managed by OpenClaw runtime). Added isReady() and
waitForReady() using /readyz for gateway readiness checks.
2026-04-10 14:53:03 -07:00
Nikhil Sonti
928cd46579 feat(openclaw): add GatewayClient WebSocket RPC client
Persistent WS client for the OpenClaw Gateway protocol. Handles the
challenge → connect → hello-ok handshake (as openclaw-control-ui with
operator.admin scope), JSON-RPC with pending map + timeouts, and
auto-reconnect. Exposes typed methods for agents.list, agents.create,
agents.delete, and health.
2026-04-10 14:52:09 -07:00
Nikhil Sonti
4e7cfb5998 fix(openclaw): write gateway auth token to openclaw.json
The gateway was returning 401 because auth.mode was set to "token"
without providing the actual token value. Now the token is written
to gateway.auth.token in openclaw.json so the gateway and our chat
proxy agree on the same token.
2026-04-10 14:17:00 -07:00
Nikhil Sonti
95df5734c6 feat(openclaw): per-agent provider selection
Each agent can now have its own LLM provider. The Create Agent dialog
includes a provider selector that passes providerType/apiKey/modelId
to the backend. The service writes per-agent model config to
openclaw.json and merges the API key into the container's .env file.
2026-04-10 14:08:54 -07:00
Nikhil Sonti
2de20344a0 feat(openclaw): add provider selector to setup flow
Add LLM provider selector using useLlmProviders hook. Filters out
OAuth-only providers, pre-selects the user's default, and passes
providerType/apiKey/modelId to the setup endpoint so OpenClaw gets
a working LLM configuration on first setup.
2026-04-10 14:03:01 -07:00
Nikhil Sonti
4a571bc750 feat(openclaw): add agents page UI with chat, create, and lifecycle controls
Add /agents route with AgentsPage showing OpenClaw status, agent list,
create dialog, and per-agent chat. Includes useOpenClaw hook for
server communication, AgentChat component with SSE streaming, and
sidebar navigation entry.
2026-04-10 11:47:28 -07:00
Nikhil Sonti
71ba53e528 fix(openclaw): resolve type errors in service and podman runtime
Fix TIMEOUTS.TOOL_EXECUTION → TIMEOUTS.TOOL_CALL to match shared
constants. Fix ReadableStream undefined/null type mismatch in
PodmanRuntime.runCommand stream draining.
2026-04-10 11:39:41 -07:00
Nikhil Sonti
9a77da8d4e feat(openclaw): add API routes and server wiring
Add /api/claw/* routes for container lifecycle (setup/start/stop/restart),
agent CRUD (list/create/delete), chat proxy with SSE streaming, provider
key management, and log retrieval. Register routes in server.ts, add
OpenClaw auto-start on BrowserOS boot and graceful shutdown in main.ts.
2026-04-10 11:38:22 -07:00
Nikhil Sonti
b1fd3bdd31 feat(openclaw): add OpenClawService orchestrator
Main service managing the single OpenClaw container. Handles full
lifecycle (setup/start/stop/restart/shutdown), agent CRUD with config
rewrites and gateway restarts, chat proxy to /v1/chat/completions,
provider key updates, auto-start on BrowserOS boot, and status reporting.
2026-04-10 11:36:24 -07:00
Nikhil Sonti
92560cb369 feat(openclaw): add config builder and container runtime
openclaw-config.ts: pure functions to build openclaw.json and .env files
from BrowserOS settings. Maps provider keys, sets permissive defaults
(full exec, cron, web search, MCP bridge to BrowserOS).

container-runtime.ts: compose-level abstraction over PodmanRuntime for
the browseros-openclaw project. Handles up/down/restart/pull, health
checks, .env file writes, and safe machine shutdown.
2026-04-10 11:34:49 -07:00
Nikhil Sonti
78b797400e feat(openclaw): add PodmanRuntime container engine abstraction
Manages Podman CLI interactions: machine lifecycle (init/start/stop),
availability checks, command execution with streaming output, and
running container enumeration. Linux skips machine ops since Podman
runs natively.
2026-04-10 11:33:42 -07:00
Nikhil Sonti
3852660b52 feat(openclaw): add foundation — paths constant, browseros-dir helper, static compose file
Add OPENCLAW_DIR_NAME to shared paths constant, getOpenClawDir() to
browseros-dir.ts, and a static docker-compose.yml resource file that
uses native .env variable substitution instead of YAML template strings.
2026-04-10 11:32:57 -07:00
16 changed files with 3156 additions and 0 deletions

View File

@@ -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',

View File

@@ -9,6 +9,7 @@ import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
import { FeaturesPage } from '../onboarding/features/Features'
import { Onboarding } from '../onboarding/index/Onboarding'
import { StepsLayout } from '../onboarding/steps/StepsLayout'
import { AgentsPage } from './agents/AgentsPage'
import { AISettingsPage } from './ai-settings/AISettingsPage'
import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { 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 */}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 }),
})
}

View File

@@ -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

View 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)
}
})
}

View File

@@ -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) => {

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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,
})
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,