mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-15 00:49:53 +00:00
Compare commits
20 Commits
dev
...
feat/secur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f3474f0b0 | ||
|
|
1d50894e68 | ||
|
|
4c2bb30f29 | ||
|
|
6acda41eff | ||
|
|
f63de331f0 | ||
|
|
d305cf217b | ||
|
|
7b2b947399 | ||
|
|
b8be74069d | ||
|
|
a4e5ad947d | ||
|
|
ef0a665da4 | ||
|
|
dfb3e8ca74 | ||
|
|
12f03af32e | ||
|
|
f8880c0b4f | ||
|
|
afa1e6b868 | ||
|
|
88b591849c | ||
|
|
d66e789e7e | ||
|
|
b8dc70a26b | ||
|
|
18fc173dfe | ||
|
|
a535cb7d86 | ||
|
|
f962512828 |
@@ -3,6 +3,7 @@ import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Compass,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
@@ -80,6 +81,7 @@ const primarySettingsSections: NavSection[] = [
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
|
||||
{ name: 'Agents', to: '/settings/agents', icon: Cpu },
|
||||
{
|
||||
name: 'Usage & Billing',
|
||||
to: '/settings/usage',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
|
||||
import { FeaturesPage } from '../onboarding/features/Features'
|
||||
import { Onboarding } from '../onboarding/index/Onboarding'
|
||||
import { StepsLayout } from '../onboarding/steps/StepsLayout'
|
||||
import { AgentsPage } from './agents/AgentsPage'
|
||||
import { AISettingsPage } from './ai-settings/AISettingsPage'
|
||||
import { ConnectMCP } from './connect-mcp/ConnectMCP'
|
||||
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
|
||||
@@ -104,6 +105,7 @@ export const App: FC = () => {
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
let msgCounter = 0
|
||||
function nextMsgId(): string {
|
||||
return `msg-${++msgCounter}`
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const messagesLength = messages.length
|
||||
const lastMessageContent = messages[messages.length - 1]?.content ?? ''
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages and content updates
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messagesLength, lastMessageContent])
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
|
||||
setInput('')
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nextMsgId(), role: 'user', content: text },
|
||||
])
|
||||
setStreaming(true)
|
||||
|
||||
// Add an empty assistant message to stream into
|
||||
const assistantId = nextMsgId()
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: assistantId, role: 'assistant', content: '' },
|
||||
])
|
||||
|
||||
try {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(`${baseUrl}/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json()
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const last = updated[updated.length - 1]
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
content: `Error: ${(err as { error?: string }).error ?? 'Unknown error'}`,
|
||||
}
|
||||
return updated
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
|
||||
try {
|
||||
const chunk = JSON.parse(line.slice(6))
|
||||
const content = chunk.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const last = updated[updated.length - 1]
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
content: last.content + content,
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const last = updated[updated.length - 1]
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
content: `Connection error: ${err instanceof Error ? err.message : 'Failed to reach agent'}`,
|
||||
}
|
||||
return updated
|
||||
})
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<div className="flex items-center gap-2 border-b pb-4">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-semibold text-lg">{agentName}</h1>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Chat with your OpenClaw agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto py-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Send a message to start chatting with your agent.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
'flex',
|
||||
msg.role === 'user' ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-2 text-sm',
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap font-sans">{msg.content}</pre>
|
||||
{msg.role === 'assistant' &&
|
||||
streaming &&
|
||||
i === messages.length - 1 &&
|
||||
!msg.content && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t pt-4">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Type a message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}}
|
||||
disabled={streaming}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || streaming}
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface AgentLogsDialogProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const AgentLogsDialog: FC<AgentLogsDialogProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
eventSource = new EventSource(`${baseUrl}/agents/${agentId}/logs`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const line = JSON.parse(event.data) as string
|
||||
setLogs((prev) => [...prev, line])
|
||||
}
|
||||
} catch {
|
||||
// Connection failed
|
||||
}
|
||||
}
|
||||
|
||||
setLogs([])
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
eventSource?.close()
|
||||
}
|
||||
}, [open, agentId])
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
const logsLength = logs.length
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: logsLength triggers scroll on new log lines
|
||||
useEffect(() => {
|
||||
if (isAtBottom.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [logsLength])
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Logs: {agentName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[400px] overflow-y-auto rounded-md border bg-black p-4"
|
||||
>
|
||||
<pre className="whitespace-pre-wrap font-mono text-green-400 text-xs">
|
||||
{logs.length === 0 ? 'Waiting for logs...' : logs.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Cpu,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { AgentChat } from './AgentChat'
|
||||
import { AgentLogsDialog } from './AgentLogsDialog'
|
||||
|
||||
// Types that use OAuth and have no raw API key to forward
|
||||
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
|
||||
|
||||
interface AgentInstance {
|
||||
id: string
|
||||
name: string
|
||||
status: 'creating' | 'running' | 'stopped' | 'error'
|
||||
port: number
|
||||
createdAt: string
|
||||
error?: string
|
||||
providerType?: string
|
||||
workspacePath?: string
|
||||
}
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const client = useRpcClient()
|
||||
const { providers, defaultProviderId } = useLlmProviders()
|
||||
const [agents, setAgents] = useState<AgentInstance[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [newAgentName, setNewAgentName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [logsAgentId, setLogsAgentId] = useState<string | null>(null)
|
||||
const [chatAgentId, setChatAgentId] = useState<string | null>(null)
|
||||
const [selectedProviderId, setSelectedProviderId] = useState('')
|
||||
const [workspacePath, setWorkspacePath] = useState<{
|
||||
name: string
|
||||
path: string
|
||||
} | null>(null)
|
||||
const triggerRefresh = () => setRefreshKey((k) => k + 1)
|
||||
|
||||
const logsAgent = logsAgentId
|
||||
? agents.find((a) => a.id === logsAgentId)
|
||||
: null
|
||||
const chatAgent = chatAgentId
|
||||
? agents.find((a) => a.id === chatAgentId)
|
||||
: null
|
||||
|
||||
// Filter providers that have an API key and can be forwarded to OpenClaw
|
||||
const compatibleProviders = useMemo(
|
||||
() => providers.filter((p) => p.apiKey && !OAUTH_ONLY_TYPES.has(p.type)),
|
||||
[providers],
|
||||
)
|
||||
|
||||
// Pre-select default provider when dialog opens
|
||||
useEffect(() => {
|
||||
if (createDialogOpen && compatibleProviders.length > 0) {
|
||||
const defaultMatch = compatibleProviders.find(
|
||||
(p) => p.id === defaultProviderId,
|
||||
)
|
||||
setSelectedProviderId(defaultMatch?.id ?? compatibleProviders[0].id)
|
||||
}
|
||||
}, [createDialogOpen, compatibleProviders, defaultProviderId])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey triggers refetch from outside the effect
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
const res = await client.agents.$get()
|
||||
const data = (await res.json()) as { agents: AgentInstance[] }
|
||||
if (!cancelled) setAgents(data.agents)
|
||||
} catch {
|
||||
// Server may not have the route yet
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkDocker = async () => {
|
||||
try {
|
||||
const res = await client.agents['docker-status'].$get()
|
||||
const data = (await res.json()) as { available: boolean }
|
||||
if (!cancelled) setDockerAvailable(data.available)
|
||||
} catch {
|
||||
if (!cancelled) setDockerAvailable(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAgents()
|
||||
checkDocker()
|
||||
const interval = setInterval(fetchAgents, 3000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [client, refreshKey])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const selectedProvider = compatibleProviders.find(
|
||||
(p) => p.id === selectedProviderId,
|
||||
)
|
||||
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const res = await fetch(`${baseUrl}/agents/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newAgentName.trim(),
|
||||
providerType: selectedProvider?.type,
|
||||
apiKey: selectedProvider?.apiKey,
|
||||
baseUrl: selectedProvider?.baseUrl,
|
||||
modelId: selectedProvider?.modelId,
|
||||
providerName: selectedProvider?.name,
|
||||
workspacePath: workspacePath?.path,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { agent: AgentInstance }
|
||||
setCreateDialogOpen(false)
|
||||
setNewAgentName('')
|
||||
setWorkspacePath(null)
|
||||
triggerRefresh()
|
||||
setLogsAgentId(data.agent.id)
|
||||
}
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const agentAction = async (
|
||||
id: string,
|
||||
action: 'stop' | 'start' | 'delete',
|
||||
) => {
|
||||
setActionInProgress(id)
|
||||
try {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const method = action === 'delete' ? 'DELETE' : 'POST'
|
||||
const actionPath =
|
||||
action === 'delete'
|
||||
? `${baseUrl}/agents/${id}`
|
||||
: `${baseUrl}/agents/${id}/${action}`
|
||||
await fetch(actionPath, { method })
|
||||
triggerRefresh()
|
||||
} finally {
|
||||
setActionInProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = (id: string) => agentAction(id, 'stop')
|
||||
const handleStart = (id: string) => agentAction(id, 'start')
|
||||
const handleDelete = (id: string) => agentAction(id, 'delete')
|
||||
|
||||
const getStatusBadge = (status: AgentInstance['status']) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
{
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
creating: { variant: 'secondary', label: 'Creating...' },
|
||||
running: { variant: 'default', label: 'Running' },
|
||||
stopped: { variant: 'outline', label: 'Stopped' },
|
||||
error: { variant: 'destructive', label: 'Error' },
|
||||
}
|
||||
const { variant, label } = variants[status] ?? variants.stopped
|
||||
return <Badge variant={variant}>{label}</Badge>
|
||||
}
|
||||
|
||||
// Show chat view when an agent is selected
|
||||
if (chatAgent) {
|
||||
return (
|
||||
<AgentChat
|
||||
agentId={chatAgent.id}
|
||||
agentName={chatAgent.name}
|
||||
onBack={() => setChatAgentId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">Agents</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage OpenClaw agent instances running in Docker
|
||||
containers.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={dockerAvailable === false}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="agent-name">
|
||||
Agent Name
|
||||
</label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
placeholder="e.g. work, personal, research"
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="agent-provider">
|
||||
LLM Provider
|
||||
</label>
|
||||
{compatibleProviders.length > 0 ? (
|
||||
<>
|
||||
<Select
|
||||
value={selectedProviderId}
|
||||
onValueChange={setSelectedProviderId}
|
||||
>
|
||||
<SelectTrigger id="agent-provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{compatibleProviders.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} — {p.modelId}
|
||||
{p.id === defaultProviderId ? ' (default)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Uses your existing API key from BrowserOS settings. The
|
||||
key is passed to the container and never leaves your
|
||||
machine.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No compatible LLM providers configured.{' '}
|
||||
<a href="#/settings/ai" className="underline">
|
||||
Add one in AI settings
|
||||
</a>{' '}
|
||||
first, or create the agent without one and configure later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="font-medium text-sm">Output Workspace</span>
|
||||
{workspacePath ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">
|
||||
{workspacePath.path}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0"
|
||||
onClick={() => setWorkspacePath(null)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const result = await adapter.choosePath({
|
||||
type: 'folder',
|
||||
})
|
||||
if (result) {
|
||||
setWorkspacePath({
|
||||
name: result.name,
|
||||
path: result.path,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// User cancelled or API not available
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Choose output folder
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
The agent saves reports and files here. This is the only
|
||||
folder the agent can access on your computer.
|
||||
{!workspacePath &&
|
||||
' If not chosen, a default folder will be created.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCreate}
|
||||
disabled={!newAgentName.trim() || creating}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Agent'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{dockerAvailable === false && (
|
||||
<Card className="border-destructive/50 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Docker is not available</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Install{' '}
|
||||
<a
|
||||
href="https://www.docker.com/products/docker-desktop/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Docker Desktop
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a
|
||||
href="https://orbstack.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
OrbStack
|
||||
</a>{' '}
|
||||
to create local OpenClaw agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Cpu className="mb-4 size-12 text-muted-foreground/50" />
|
||||
<h3 className="font-medium text-lg">No agents yet</h3>
|
||||
<p className="mt-1 max-w-sm text-muted-foreground text-sm">
|
||||
Create your first OpenClaw agent to get started. Each agent runs in
|
||||
an isolated Docker container with its own workspace.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Cpu className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{getStatusBadge(agent.status)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Port {agent.port}
|
||||
{agent.workspacePath && (
|
||||
<> · {agent.workspacePath}</>
|
||||
)}
|
||||
</p>
|
||||
{agent.error && (
|
||||
<p className="mt-1 text-destructive text-xs">
|
||||
{agent.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{agent.status === 'running' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => setChatAgentId(agent.id)}
|
||||
title="Chat"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => setLogsAgentId(agent.id)}
|
||||
title="View logs"
|
||||
>
|
||||
<ScrollText className="size-4" />
|
||||
</Button>
|
||||
{agent.status === 'running' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => handleStop(agent.id)}
|
||||
disabled={actionInProgress === agent.id}
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{agent.status === 'stopped' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => handleStart(agent.id)}
|
||||
disabled={actionInProgress === agent.id}
|
||||
title="Start"
|
||||
>
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(agent.status === 'stopped' || agent.status === 'error') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(agent.id)}
|
||||
disabled={actionInProgress === agent.id}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{actionInProgress === agent.id && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logsAgent && (
|
||||
<AgentLogsDialog
|
||||
agentId={logsAgent.id}
|
||||
agentName={logsAgent.name}
|
||||
open={!!logsAgentId}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setLogsAgentId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"scripts": {
|
||||
"start": "bun --watch --env-file=.env.development src/index.ts",
|
||||
"build": "bun ../../scripts/build/server.ts --target=all",
|
||||
"fetch:klavis-tools": "bun --env-file=.env.development ../../scripts/fetch-klavis-tool-catalog.ts",
|
||||
"test:tools": "bun run test:cleanup && bun --env-file=.env.development test tests/tools",
|
||||
"test:integration": "bun run test:cleanup && bun --env-file=.env.development test tests/server.integration.test.ts",
|
||||
"test:sdk": "bun run test:cleanup && bun --env-file=.env.development test tests/sdk",
|
||||
|
||||
884
packages/browseros-agent/apps/server/src/api/routes/agents.ts
Normal file
884
packages/browseros-agent/apps/server/src/api/routes/agents.ts
Normal file
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Agent management routes for OpenClaw container instances.
|
||||
* Uses named Docker volumes for secure internal persistence.
|
||||
* User workspace is the only host directory exposed to the container.
|
||||
* Provides SSE log streaming for real-time setup visibility.
|
||||
* Persists agent metadata to ~/.browseros/agents.json.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { getBrowserosDir } from '../../lib/browseros-dir'
|
||||
import { logger } from '../../lib/logger'
|
||||
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
|
||||
const MAX_LOG_LINES = 1000
|
||||
|
||||
// Maps BrowserOS provider types to OpenClaw environment variable names
|
||||
const OPENCLAW_PROVIDER_ENV_MAP: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GEMINI_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
groq: 'GROQ_API_KEY',
|
||||
mistral: 'MISTRAL_API_KEY',
|
||||
}
|
||||
|
||||
// Persisted to agents.json
|
||||
interface AgentRecord {
|
||||
id: string
|
||||
name: string
|
||||
status: 'creating' | 'running' | 'stopped' | 'error'
|
||||
port: number
|
||||
dir: string
|
||||
token: string
|
||||
createdAt: string
|
||||
error?: string
|
||||
providerType?: string
|
||||
workspacePath: string
|
||||
}
|
||||
|
||||
// Runtime-only (not persisted)
|
||||
interface AgentRuntime {
|
||||
logs: string[]
|
||||
logListeners: Set<(line: string) => void>
|
||||
}
|
||||
|
||||
type AgentInstance = AgentRecord & AgentRuntime
|
||||
|
||||
// ─── Persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
function getAgentsJsonPath(): string {
|
||||
return path.join(getBrowserosDir(), 'agents.json')
|
||||
}
|
||||
|
||||
function getAgentsBaseDir(): string {
|
||||
return path.join(getBrowserosDir(), 'agents')
|
||||
}
|
||||
|
||||
function getDefaultWorkspacePath(agentName: string): string {
|
||||
return path.join(os.homedir(), 'Documents', 'BrowserOS Agents', agentName)
|
||||
}
|
||||
|
||||
const instances = new Map<string, AgentInstance>()
|
||||
|
||||
function saveAgents(): void {
|
||||
const records: AgentRecord[] = Array.from(instances.values()).map(
|
||||
({ logs: _, logListeners: __, ...record }) => record,
|
||||
)
|
||||
try {
|
||||
const filePath = getAgentsJsonPath()
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, JSON.stringify(records, null, 2))
|
||||
} catch (err) {
|
||||
logger.warn('Failed to save agents.json', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loadAgents(): void {
|
||||
try {
|
||||
const filePath = getAgentsJsonPath()
|
||||
if (!fs.existsSync(filePath)) return
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as AgentRecord[]
|
||||
for (const record of data) {
|
||||
instances.set(record.id, {
|
||||
...record,
|
||||
logs: [],
|
||||
logListeners: new Set(),
|
||||
})
|
||||
}
|
||||
logger.info(`Loaded ${data.length} agent(s) from agents.json`)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load agents.json', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
loadAgents()
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function pushLog(instance: AgentInstance, line: string) {
|
||||
const timestamped = `[${new Date().toISOString().slice(11, 19)}] ${line}`
|
||||
instance.logs.push(timestamped)
|
||||
if (instance.logs.length > MAX_LOG_LINES) {
|
||||
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES)
|
||||
}
|
||||
for (const listener of instance.logListeners) {
|
||||
listener(timestamped)
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(
|
||||
instance: AgentInstance,
|
||||
status: AgentRecord['status'],
|
||||
error?: string,
|
||||
): void {
|
||||
instance.status = status
|
||||
instance.error = error
|
||||
saveAgents()
|
||||
}
|
||||
|
||||
async function isDockerAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn(['docker', 'info'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const exitCode = await proc.exited
|
||||
return exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
const net = await import('node:net')
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.listen(startPort, '127.0.0.1', () => {
|
||||
server.close(() => resolve(startPort))
|
||||
})
|
||||
server.on('error', () => {
|
||||
resolve(findAvailablePort(startPort + 1))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function streamProcessOutput(
|
||||
proc: ReturnType<typeof Bun.spawn>,
|
||||
instance: AgentInstance,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const seen = new Set<string>()
|
||||
const dedup =
|
||||
(tag: string) => async (readable: ReadableStream<Uint8Array>) => {
|
||||
const reader = readable.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && !seen.has(trimmed)) {
|
||||
seen.add(trimmed)
|
||||
pushLog(instance, `[${tag}] ${trimmed}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const trimmed = buf.trim()
|
||||
if (trimmed && !seen.has(trimmed)) {
|
||||
seen.add(trimmed)
|
||||
pushLog(instance, `[${tag}] ${trimmed}`)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
dedup(prefix)(proc.stdout as ReadableStream<Uint8Array>),
|
||||
dedup(prefix)(proc.stderr as ReadableStream<Uint8Array>),
|
||||
])
|
||||
}
|
||||
|
||||
async function runCommandWithLogs(
|
||||
instance: AgentInstance,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string; env?: Record<string, string> },
|
||||
): Promise<number> {
|
||||
const proc = Bun.spawn([cmd, ...args], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env, ...options?.env },
|
||||
})
|
||||
await streamProcessOutput(proc, instance, cmd)
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
function composeEnv(name: string): Record<string, string> {
|
||||
return { COMPOSE_PROJECT_NAME: `browseros-claw-${name}` }
|
||||
}
|
||||
|
||||
// ─── Generators ─────────────────────────────────────────────────────────────
|
||||
|
||||
function generateComposeFile(config: {
|
||||
image: string
|
||||
gatewayPort: number
|
||||
token: string
|
||||
homeVolumeName: string
|
||||
workspacePath: string
|
||||
}): string {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
return `services:
|
||||
openclaw-gateway:
|
||||
image: ${config.image}
|
||||
ports:
|
||||
- "127.0.0.1:${config.gatewayPort}:18789"
|
||||
environment:
|
||||
- OPENCLAW_GATEWAY_TOKEN=${config.token}
|
||||
- TZ=${tz}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ${config.homeVolumeName}:/home/node
|
||||
- ${config.workspacePath}:/workspace
|
||||
command: node dist/index.js gateway --bind lan --port 18789
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
${config.homeVolumeName}:
|
||||
`
|
||||
}
|
||||
|
||||
function generateOpenClawConfig(config: {
|
||||
port: number
|
||||
browserosServerPort: number
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
providerName?: string
|
||||
}): Record<string, unknown> {
|
||||
const openclawConfig: Record<string, unknown> = {
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
controlUi: {
|
||||
allowedOrigins: [
|
||||
`http://127.0.0.1:${config.port}`,
|
||||
`http://localhost:${config.port}`,
|
||||
],
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
servers: {
|
||||
browseros: {
|
||||
url: `http://host.docker.internal:${config.browserosServerPort}/mcp`,
|
||||
transport: 'streamable-http',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (!config.apiKey || !config.providerType) {
|
||||
return openclawConfig
|
||||
}
|
||||
|
||||
const directEnvVar = OPENCLAW_PROVIDER_ENV_MAP[config.providerType]
|
||||
|
||||
if (directEnvVar) {
|
||||
openclawConfig.env = { [directEnvVar]: config.apiKey }
|
||||
if (config.modelId) {
|
||||
openclawConfig.agents = {
|
||||
defaults: {
|
||||
model: { primary: `${config.providerType}/${config.modelId}` },
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (config.baseUrl) {
|
||||
const providerId = (config.providerName || 'custom-provider')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
const envVarName = `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
|
||||
openclawConfig.env = { [envVarName]: config.apiKey }
|
||||
openclawConfig.models = {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
[providerId]: {
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: `\${${envVarName}}`,
|
||||
api: 'openai-completions',
|
||||
...(config.modelId
|
||||
? { models: [{ id: config.modelId, name: config.modelId }] }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
if (config.modelId) {
|
||||
openclawConfig.agents = {
|
||||
defaults: {
|
||||
model: { primary: `${providerId}/${config.modelId}` },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openclawConfig
|
||||
}
|
||||
|
||||
function generateSoulMd(): string {
|
||||
const lines = [
|
||||
'# SOUL.md',
|
||||
'',
|
||||
'You are an AI assistant running inside BrowserOS.',
|
||||
'',
|
||||
'## Core Truths',
|
||||
'- **Results Over Process** — Do not explain what you are going to do. Just do it.',
|
||||
'- **Ownership** — When you take on a task, you own it end-to-end.',
|
||||
'- **Output Goes to /workspace** — All reports, documents, and files you create for the user MUST be saved to /workspace. This is the only directory the user can see from their computer.',
|
||||
'',
|
||||
'## Environment',
|
||||
'- You are running inside a Docker container managed by BrowserOS',
|
||||
'- Your internal workspace is at ~/.openclaw/workspace (for your own notes and memory)',
|
||||
'- The output directory is mounted at /workspace — save all deliverables here',
|
||||
'- You have access to BrowserOS MCP tools for web browsing, taking screenshots, filling forms, and accessing 40+ connected apps (Gmail, Slack, Notion, GitHub, etc.)',
|
||||
'',
|
||||
'## Boundaries',
|
||||
'- Do NOT attempt to access the host filesystem outside /workspace',
|
||||
'- Do NOT modify your own configuration files',
|
||||
'- Save internal notes to ~/.openclaw/workspace/memory/ (your private memory)',
|
||||
'- Save user-facing output to /workspace (their computer)',
|
||||
'',
|
||||
'## Vibe',
|
||||
'- Direct, concise, helpful',
|
||||
'- When asked to produce a report or document, write it to /workspace and tell the user the filename',
|
||||
'- When you need web data or app data, use the BrowserOS MCP tools',
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function generateAgentsMd(): string {
|
||||
const lines = [
|
||||
'# AGENTS.md',
|
||||
'',
|
||||
'## Output Directory',
|
||||
'All files intended for the user (reports, documents, exports, analysis) must be written to:',
|
||||
'```',
|
||||
'/workspace/',
|
||||
'```',
|
||||
'This directory is visible on the user host computer. Files saved here appear immediately in their chosen folder.',
|
||||
'',
|
||||
'## Internal Directory',
|
||||
'Your internal working files (memory, notes, drafts) go to:',
|
||||
'```',
|
||||
'~/.openclaw/workspace/',
|
||||
'```',
|
||||
'This is your private workspace inside the container.',
|
||||
'',
|
||||
'## Available Tools',
|
||||
'',
|
||||
'### BrowserOS MCP (web and apps)',
|
||||
'You have access to BrowserOS tools via MCP:',
|
||||
'- **Browser automation**: navigate pages, click, fill forms, take screenshots, extract content',
|
||||
'- **Connected apps**: Gmail, Slack, Notion, GitHub, Linear, Google Docs, and 40+ more',
|
||||
'- Use these tools when you need data from the web or from the user connected applications',
|
||||
'',
|
||||
'### File System',
|
||||
'- Read and write files in /workspace (user output) and ~/.openclaw/workspace (internal)',
|
||||
'- Install tools via apt or npm if needed (persists across restarts)',
|
||||
'',
|
||||
'### Memory',
|
||||
'- Save important facts to MEMORY.md (loaded every session)',
|
||||
'- Save daily context to memory/YYYY-MM-DD.md',
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function dumpContainerLogs(
|
||||
instance: AgentInstance,
|
||||
agentDir: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(path.join(agentDir, 'docker-compose.yml'))) {
|
||||
pushLog(instance, '--- Container logs ---')
|
||||
await runCommandWithLogs(
|
||||
instance,
|
||||
'docker',
|
||||
['compose', 'logs', '--no-color', '--tail', '50'],
|
||||
{ cwd: agentDir, env: composeEnv(name) },
|
||||
)
|
||||
pushLog(instance, '--- End container logs ---')
|
||||
}
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createAgentsRoutes(config: { serverPort: number }) {
|
||||
return new Hono()
|
||||
.get('/', (c) => {
|
||||
const agentList = Array.from(instances.values()).map(
|
||||
({ logListeners: _, logs: __, ...rest }) => rest,
|
||||
)
|
||||
return c.json({ agents: agentList })
|
||||
})
|
||||
|
||||
.get('/docker-status', async (c) => {
|
||||
const available = await isDockerAvailable()
|
||||
return c.json({ available })
|
||||
})
|
||||
|
||||
.get('/:id/logs', (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('Connection', 'keep-alive')
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const write = async (line: string) => {
|
||||
await s.write(`data: ${JSON.stringify(line)}\n\n`)
|
||||
}
|
||||
|
||||
for (const line of instance.logs) {
|
||||
await write(line)
|
||||
}
|
||||
|
||||
const onLog = (line: string) => {
|
||||
write(line).catch(() => {
|
||||
instance.logListeners.delete(onLog)
|
||||
})
|
||||
}
|
||||
instance.logListeners.add(onLog)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
s.onAbort(() => {
|
||||
instance.logListeners.delete(onLog)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
.post('/create', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
providerName?: string
|
||||
workspacePath?: string
|
||||
}>()
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Name must start with a letter or number and contain only letters, numbers, dots, hyphens, and underscores',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const existing = Array.from(instances.values()).find(
|
||||
(i) => i.name === name,
|
||||
)
|
||||
if (existing) {
|
||||
return c.json({ error: `Agent "${name}" already exists` }, 409)
|
||||
}
|
||||
|
||||
const dockerAvailable = await isDockerAvailable()
|
||||
if (!dockerAvailable) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Docker is not available. Install Docker Desktop or OrbStack to create local agents.',
|
||||
},
|
||||
503,
|
||||
)
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const port = await findAvailablePort(18789)
|
||||
const agentDir = path.join(getAgentsBaseDir(), name)
|
||||
const token = crypto.randomUUID()
|
||||
const homeVolumeName = `browseros-claw-${name}-home`
|
||||
const workspacePath =
|
||||
body.workspacePath?.trim() || getDefaultWorkspacePath(name)
|
||||
|
||||
const instance: AgentInstance = {
|
||||
id,
|
||||
name,
|
||||
status: 'creating',
|
||||
port,
|
||||
dir: agentDir,
|
||||
token,
|
||||
createdAt: new Date().toISOString(),
|
||||
providerType: body.providerType,
|
||||
workspacePath,
|
||||
logs: [],
|
||||
logListeners: new Set(),
|
||||
}
|
||||
instances.set(id, instance)
|
||||
saveAgents()
|
||||
|
||||
logger.info('Creating OpenClaw agent instance', {
|
||||
id,
|
||||
name,
|
||||
port,
|
||||
dir: agentDir,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
// Set up and start in the background
|
||||
;(async () => {
|
||||
try {
|
||||
// Create agent dir and workspace on host
|
||||
fs.mkdirSync(agentDir, { recursive: true })
|
||||
fs.mkdirSync(workspacePath, { recursive: true })
|
||||
pushLog(instance, 'Created agent directories')
|
||||
|
||||
// Generate docker-compose.yml (named volume + workspace bind mount)
|
||||
const composeContent = generateComposeFile({
|
||||
image: OPENCLAW_IMAGE,
|
||||
gatewayPort: port,
|
||||
token,
|
||||
homeVolumeName,
|
||||
workspacePath,
|
||||
})
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, 'docker-compose.yml'),
|
||||
composeContent,
|
||||
)
|
||||
pushLog(instance, 'Generated docker-compose.yml')
|
||||
|
||||
// Generate config files to inject via docker cp
|
||||
const tmpDir = path.join(agentDir, '.tmp')
|
||||
fs.mkdirSync(tmpDir, { recursive: true })
|
||||
|
||||
const openclawConfig = generateOpenClawConfig({
|
||||
port,
|
||||
browserosServerPort: config.serverPort,
|
||||
providerType: body.providerType,
|
||||
apiKey: body.apiKey,
|
||||
baseUrl: body.baseUrl,
|
||||
modelId: body.modelId,
|
||||
providerName: body.providerName,
|
||||
})
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'openclaw.json'),
|
||||
JSON.stringify(openclawConfig, null, 2),
|
||||
)
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'SOUL.md'), generateSoulMd())
|
||||
fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), generateAgentsMd())
|
||||
pushLog(instance, 'Generated configuration files')
|
||||
|
||||
// Pull image
|
||||
pushLog(instance, `Pulling image ${OPENCLAW_IMAGE}...`)
|
||||
const pullExit = await runCommandWithLogs(
|
||||
instance,
|
||||
'docker',
|
||||
['compose', 'pull', '--quiet'],
|
||||
{ cwd: agentDir, env: composeEnv(name) },
|
||||
)
|
||||
if (pullExit !== 0) {
|
||||
throw new Error('Failed to pull OpenClaw image')
|
||||
}
|
||||
pushLog(instance, 'Image pulled successfully')
|
||||
|
||||
// Start the container (gateway will crash — no config yet, but
|
||||
// we need the container running so docker exec works)
|
||||
pushLog(instance, 'Initializing container...')
|
||||
await runCommandWithLogs(
|
||||
instance,
|
||||
'docker',
|
||||
['compose', 'up', '-d'],
|
||||
{ cwd: agentDir, env: composeEnv(name) },
|
||||
)
|
||||
await Bun.sleep(1000)
|
||||
|
||||
const containerName = `browseros-claw-${name}-openclaw-gateway-1`
|
||||
|
||||
// Create the directory structure inside the named volume
|
||||
// (OpenClaw doesn't create ~/.openclaw on its own when it crashes)
|
||||
pushLog(instance, 'Creating config directories...')
|
||||
await runCommandWithLogs(instance, 'docker', [
|
||||
'exec',
|
||||
containerName,
|
||||
'mkdir',
|
||||
'-p',
|
||||
'/home/node/.openclaw/workspace',
|
||||
])
|
||||
|
||||
// Copy config files into the container
|
||||
pushLog(instance, 'Injecting configuration...')
|
||||
for (const [src, dest] of [
|
||||
['openclaw.json', '/home/node/.openclaw/openclaw.json'],
|
||||
['SOUL.md', '/home/node/.openclaw/workspace/SOUL.md'],
|
||||
['AGENTS.md', '/home/node/.openclaw/workspace/AGENTS.md'],
|
||||
]) {
|
||||
const cpExit = await runCommandWithLogs(instance, 'docker', [
|
||||
'cp',
|
||||
path.join(tmpDir, src),
|
||||
`${containerName}:${dest}`,
|
||||
])
|
||||
if (cpExit !== 0) {
|
||||
throw new Error(`Failed to inject ${src}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix ownership — docker cp creates files as root
|
||||
await runCommandWithLogs(instance, 'docker', [
|
||||
'exec',
|
||||
containerName,
|
||||
'chown',
|
||||
'-R',
|
||||
'node:node',
|
||||
'/home/node/.openclaw',
|
||||
])
|
||||
|
||||
// Clean up temp files
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
pushLog(instance, 'Configuration injected')
|
||||
|
||||
// Restart so the gateway picks up the new config
|
||||
pushLog(instance, 'Starting OpenClaw gateway...')
|
||||
await runCommandWithLogs(instance, 'docker', ['compose', 'restart'], {
|
||||
cwd: agentDir,
|
||||
env: composeEnv(name),
|
||||
})
|
||||
|
||||
// Wait for health check
|
||||
pushLog(instance, 'Waiting for gateway to be ready...')
|
||||
let healthy = false
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
if (res.ok) {
|
||||
healthy = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
|
||||
if (!healthy) {
|
||||
await dumpContainerLogs(instance, agentDir, name)
|
||||
throw new Error('Gateway did not become healthy within 30 seconds')
|
||||
}
|
||||
|
||||
pushLog(
|
||||
instance,
|
||||
`OpenClaw gateway is ready at ws://127.0.0.1:${port}`,
|
||||
)
|
||||
pushLog(instance, `Control UI available at http://127.0.0.1:${port}`)
|
||||
pushLog(instance, `Output workspace: ${workspacePath}`)
|
||||
updateStatus(instance, 'running')
|
||||
logger.info('OpenClaw agent instance started', { id, name, port })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
pushLog(instance, `ERROR: ${message}`)
|
||||
await dumpContainerLogs(instance, agentDir, name)
|
||||
updateStatus(instance, 'error', message)
|
||||
logger.error('Failed to create OpenClaw agent instance', {
|
||||
id,
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return c.json(
|
||||
{
|
||||
agent: {
|
||||
id,
|
||||
name,
|
||||
status: 'creating',
|
||||
port,
|
||||
dir: agentDir,
|
||||
token,
|
||||
workspacePath,
|
||||
createdAt: instance.createdAt,
|
||||
},
|
||||
},
|
||||
201,
|
||||
)
|
||||
})
|
||||
|
||||
.post('/:id/stop', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
pushLog(instance, 'Stopping agent...')
|
||||
await runCommandWithLogs(instance, 'docker', ['compose', 'stop'], {
|
||||
cwd: instance.dir,
|
||||
env: composeEnv(instance.name),
|
||||
})
|
||||
updateStatus(instance, 'stopped')
|
||||
pushLog(instance, 'Agent stopped')
|
||||
return c.json({
|
||||
agent: {
|
||||
id,
|
||||
name: instance.name,
|
||||
status: instance.status,
|
||||
port: instance.port,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
pushLog(instance, `ERROR stopping: ${message}`)
|
||||
return c.json({ error: `Failed to stop agent: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/:id/start', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
pushLog(instance, 'Starting agent...')
|
||||
await runCommandWithLogs(instance, 'docker', ['compose', 'up', '-d'], {
|
||||
cwd: instance.dir,
|
||||
env: composeEnv(instance.name),
|
||||
})
|
||||
updateStatus(instance, 'running')
|
||||
pushLog(instance, 'Agent started')
|
||||
return c.json({
|
||||
agent: {
|
||||
id,
|
||||
name: instance.name,
|
||||
status: instance.status,
|
||||
port: instance.port,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
pushLog(instance, `ERROR starting: ${message}`)
|
||||
return c.json({ error: `Failed to start agent: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/:id/chat', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
if (instance.status !== 'running') {
|
||||
return c.json({ error: 'Agent is not running' }, 400)
|
||||
}
|
||||
|
||||
const body = await c.req.json<{ message: string }>()
|
||||
if (!body.message?.trim()) {
|
||||
return c.json({ error: 'Message is required' }, 400)
|
||||
}
|
||||
|
||||
const openclawUrl = `http://127.0.0.1:${instance.port}/v1/chat/completions`
|
||||
|
||||
try {
|
||||
const response = await fetch(openclawUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${instance.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openclaw/default',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: body.message.trim() }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text()
|
||||
return c.json(
|
||||
{ error: `OpenClaw error: ${errText}` },
|
||||
response.status as 400,
|
||||
)
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = (
|
||||
response.body as ReadableStream<Uint8Array>
|
||||
).getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(value)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: `Failed to chat: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.delete('/:id', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
for (const listener of instance.logListeners) {
|
||||
instance.logListeners.delete(listener)
|
||||
}
|
||||
|
||||
// down -v removes the named volume too
|
||||
await runCommandWithLogs(
|
||||
instance,
|
||||
'docker',
|
||||
['compose', 'down', '-v'],
|
||||
{ cwd: instance.dir, env: composeEnv(instance.name) },
|
||||
)
|
||||
// Remove compose file dir (NOT the user's workspace)
|
||||
fs.rmSync(instance.dir, { recursive: true, force: true })
|
||||
instances.delete(id)
|
||||
saveAgents()
|
||||
return c.json({ success: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: `Failed to delete agent: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { createAgentsRoutes } from './routes/agents'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
import { createGraphRoutes } from './routes/graph'
|
||||
@@ -104,6 +105,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/agents', createAgentsRoutes({ serverPort: port }))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
.route(
|
||||
'/shutdown',
|
||||
|
||||
@@ -10,6 +10,10 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { jsonSchemaObjectToZodRawShape } from 'zod-from-json-schema'
|
||||
import {
|
||||
classifyKlavisToolName,
|
||||
summarizeKlavisToolExposure,
|
||||
} from '../../../lib/clients/klavis/action-classifier'
|
||||
import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
@@ -80,6 +84,9 @@ export async function connectKlavisProxy(
|
||||
logger.info('Klavis proxy connected', {
|
||||
toolCount: tools.length,
|
||||
serverCount: allServers.length,
|
||||
classifications: summarizeKlavisToolExposure(
|
||||
tools.map((tool) => tool.name),
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -97,6 +104,7 @@ export function registerKlavisTools(
|
||||
): void {
|
||||
for (const tool of handle.tools) {
|
||||
const inputSchema = handle.inputSchemas.get(tool.name)
|
||||
const classification = classifyKlavisToolName(tool.name)
|
||||
|
||||
mcpServer.registerTool(
|
||||
tool.name,
|
||||
@@ -112,6 +120,8 @@ export function registerKlavisTools(
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: tool.name,
|
||||
source: 'mcp',
|
||||
capability_type: classification.capabilityType,
|
||||
risk_level: classification.riskLevel,
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: !result.isError,
|
||||
})
|
||||
@@ -124,6 +134,8 @@ export function registerKlavisTools(
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: tool.name,
|
||||
source: 'mcp',
|
||||
capability_type: classification.capabilityType,
|
||||
risk_level: classification.riskLevel,
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: false,
|
||||
error_message: errorText,
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
KlavisCapabilityClassification,
|
||||
KlavisCapabilityType,
|
||||
KlavisEffectType,
|
||||
KlavisExternalActionRef,
|
||||
KlavisPolicyFamily,
|
||||
KlavisResourceKind,
|
||||
KlavisRiskLevel,
|
||||
} from '@browseros/shared/types/klavis-classification'
|
||||
import { OAUTH_MCP_SERVERS } from './oauth-mcp-servers'
|
||||
|
||||
export interface KlavisServerProfile {
|
||||
serverName: string
|
||||
resourceKind: KlavisResourceKind
|
||||
defaultPolicyFamily: KlavisPolicyFamily
|
||||
defaultRiskLevel: KlavisRiskLevel
|
||||
defaultEffectType: KlavisEffectType
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface KlavisActionPatternRule {
|
||||
id: string
|
||||
test: (input: {
|
||||
profile?: KlavisServerProfile
|
||||
normalizedCategory: string
|
||||
normalizedAction: string
|
||||
combinedText: string
|
||||
}) => boolean
|
||||
apply: (
|
||||
base: KlavisCapabilityClassification,
|
||||
) => KlavisCapabilityClassification
|
||||
}
|
||||
|
||||
function createToolClassification(config: {
|
||||
toolName: string
|
||||
capabilityType: KlavisCapabilityType
|
||||
riskLevel: KlavisRiskLevel
|
||||
effectType: KlavisEffectType
|
||||
resourceKind?: KlavisResourceKind
|
||||
policyFamily: KlavisPolicyFamily
|
||||
requiresConfirmedIntent?: boolean
|
||||
supportsDraftMode?: boolean
|
||||
notes?: string
|
||||
}): KlavisCapabilityClassification {
|
||||
return {
|
||||
surface: 'strata_tool',
|
||||
normalizedKey: `strata_tool:${config.toolName}`,
|
||||
toolName: config.toolName,
|
||||
capabilityType: config.capabilityType,
|
||||
riskLevel: config.riskLevel,
|
||||
effectType: config.effectType,
|
||||
resourceKind: config.resourceKind ?? 'unknown',
|
||||
policyFamily: config.policyFamily,
|
||||
requiresConfirmedIntent: config.requiresConfirmedIntent ?? false,
|
||||
supportsDraftMode: config.supportsDraftMode ?? false,
|
||||
notes: config.notes,
|
||||
}
|
||||
}
|
||||
|
||||
export const KLAVIS_STATIC_TOOL_CLASSIFICATIONS = new Map(
|
||||
[
|
||||
createToolClassification({
|
||||
toolName: 'discover_server_categories_or_actions',
|
||||
capabilityType: 'discovery',
|
||||
riskLevel: 'low',
|
||||
effectType: 'read_only',
|
||||
policyFamily: 'safe_meta',
|
||||
notes: 'Entry point for Strata capability discovery.',
|
||||
}),
|
||||
createToolClassification({
|
||||
toolName: 'get_category_actions',
|
||||
capabilityType: 'discovery',
|
||||
riskLevel: 'low',
|
||||
effectType: 'read_only',
|
||||
policyFamily: 'safe_meta',
|
||||
}),
|
||||
createToolClassification({
|
||||
toolName: 'get_action_details',
|
||||
capabilityType: 'discovery',
|
||||
riskLevel: 'low',
|
||||
effectType: 'read_only',
|
||||
policyFamily: 'safe_meta',
|
||||
notes: 'Action schema inspection before execution.',
|
||||
}),
|
||||
createToolClassification({
|
||||
toolName: 'search_documentation',
|
||||
capabilityType: 'documentation_lookup',
|
||||
riskLevel: 'low',
|
||||
effectType: 'read_only',
|
||||
policyFamily: 'safe_meta',
|
||||
}),
|
||||
createToolClassification({
|
||||
toolName: 'execute_action',
|
||||
capabilityType: 'unknown',
|
||||
riskLevel: 'high',
|
||||
effectType: 'unknown',
|
||||
policyFamily: 'unknown',
|
||||
requiresConfirmedIntent: true,
|
||||
notes:
|
||||
'Dynamic dispatcher; downstream action must be classified from server/category/action args.',
|
||||
}),
|
||||
createToolClassification({
|
||||
toolName: 'handle_auth_failure',
|
||||
capabilityType: 'auth',
|
||||
riskLevel: 'medium',
|
||||
effectType: 'external_side_effect',
|
||||
resourceKind: 'admin',
|
||||
policyFamily: 'admin_control',
|
||||
requiresConfirmedIntent: true,
|
||||
notes: 'Authentication helper tool on Strata surfaces.',
|
||||
}),
|
||||
].map((classification) => [classification.toolName, classification]),
|
||||
)
|
||||
|
||||
const SERVER_PROFILE_OVERRIDES: Record<
|
||||
string,
|
||||
Omit<KlavisServerProfile, 'serverName'>
|
||||
> = {
|
||||
Gmail: {
|
||||
resourceKind: 'email',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
notes: 'Email read and send surface.',
|
||||
},
|
||||
Slack: {
|
||||
resourceKind: 'message',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
LinkedIn: {
|
||||
resourceKind: 'message',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Notion: {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Airtable: {
|
||||
resourceKind: 'task',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Confluence: {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
GitHub: {
|
||||
resourceKind: 'repository',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
GitLab: {
|
||||
resourceKind: 'repository',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Linear: {
|
||||
resourceKind: 'issue',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Jira: {
|
||||
resourceKind: 'ticket',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Salesforce: {
|
||||
resourceKind: 'contact',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
ClickUp: {
|
||||
resourceKind: 'task',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Asana: {
|
||||
resourceKind: 'task',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Monday: {
|
||||
resourceKind: 'task',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Microsoft Teams': {
|
||||
resourceKind: 'message',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Outlook Mail': {
|
||||
resourceKind: 'email',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Outlook Calendar': {
|
||||
resourceKind: 'calendar_event',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Supabase: {
|
||||
resourceKind: 'admin',
|
||||
defaultPolicyFamily: 'admin_control',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Vercel: {
|
||||
resourceKind: 'deployment',
|
||||
defaultPolicyFamily: 'admin_control',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Postman: {
|
||||
resourceKind: 'admin',
|
||||
defaultPolicyFamily: 'admin_control',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Stripe: {
|
||||
resourceKind: 'payment',
|
||||
defaultPolicyFamily: 'financial',
|
||||
defaultRiskLevel: 'critical',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Cloudflare: {
|
||||
resourceKind: 'admin',
|
||||
defaultPolicyFamily: 'admin_control',
|
||||
defaultRiskLevel: 'critical',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Brave Search': {
|
||||
resourceKind: 'unknown',
|
||||
defaultPolicyFamily: 'read_only_external',
|
||||
defaultRiskLevel: 'low',
|
||||
defaultEffectType: 'read_only',
|
||||
},
|
||||
Mem0: {
|
||||
resourceKind: 'unknown',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Dropbox: {
|
||||
resourceKind: 'file',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
OneDrive: {
|
||||
resourceKind: 'file',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
WordPress: {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
YouTube: {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'read_only_external',
|
||||
defaultRiskLevel: 'low',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Box: {
|
||||
resourceKind: 'file',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
HubSpot: {
|
||||
resourceKind: 'contact',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
PostHog: {
|
||||
resourceKind: 'analytics',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Mixpanel: {
|
||||
resourceKind: 'analytics',
|
||||
defaultPolicyFamily: 'read_only_external',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Discord: {
|
||||
resourceKind: 'message',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
WhatsApp: {
|
||||
resourceKind: 'message',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'critical',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Shopify: {
|
||||
resourceKind: 'payment',
|
||||
defaultPolicyFamily: 'financial',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Cal.com': {
|
||||
resourceKind: 'calendar_event',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Resend: {
|
||||
resourceKind: 'email',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'critical',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Google Calendar': {
|
||||
resourceKind: 'calendar_event',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Google Docs': {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Google Drive': {
|
||||
resourceKind: 'file',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Google Sheets': {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
'Google Forms': {
|
||||
resourceKind: 'document',
|
||||
defaultPolicyFamily: 'external_mutation',
|
||||
defaultRiskLevel: 'medium',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Zendesk: {
|
||||
resourceKind: 'ticket',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
Intercom: {
|
||||
resourceKind: 'message',
|
||||
defaultPolicyFamily: 'communications',
|
||||
defaultRiskLevel: 'high',
|
||||
defaultEffectType: 'unknown',
|
||||
},
|
||||
}
|
||||
|
||||
export const KLAVIS_SERVER_PROFILES = new Map<string, KlavisServerProfile>(
|
||||
OAUTH_MCP_SERVERS.map((server) => [
|
||||
server.name,
|
||||
Object.assign(
|
||||
{
|
||||
serverName: server.name,
|
||||
resourceKind: 'unknown' as const,
|
||||
defaultPolicyFamily: 'unknown' as const,
|
||||
defaultRiskLevel: 'unknown' as const,
|
||||
defaultEffectType: 'unknown' as const,
|
||||
},
|
||||
SERVER_PROFILE_OVERRIDES[server.name] ?? {},
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
function textMatcher(pattern: RegExp): KlavisActionPatternRule['test'] {
|
||||
return ({ combinedText }) => pattern.test(combinedText)
|
||||
}
|
||||
|
||||
function normalizeActionText(actionName?: string): string {
|
||||
return (actionName ?? '').replaceAll(/[_-]+/g, ' ')
|
||||
}
|
||||
|
||||
function applyClassification(
|
||||
base: KlavisCapabilityClassification,
|
||||
patch: Partial<KlavisCapabilityClassification>,
|
||||
): KlavisCapabilityClassification {
|
||||
return { ...base, ...patch }
|
||||
}
|
||||
|
||||
export const KLAVIS_ACTION_PATTERN_RULES: KlavisActionPatternRule[] = [
|
||||
{
|
||||
id: 'financial',
|
||||
test: textMatcher(
|
||||
/\b(pay|charge|refund|invoice|subscription|transfer|payout|billing)\b/i,
|
||||
),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: 'financial_operation',
|
||||
riskLevel: 'critical',
|
||||
effectType: 'external_side_effect',
|
||||
resourceKind: 'payment',
|
||||
policyFamily: 'financial',
|
||||
requiresConfirmedIntent: true,
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
test: textMatcher(
|
||||
/\b(permission|permissions|role|member|token|secret|api key|dns|deploy|deployment|environment variable|setting|settings|config)\b/i,
|
||||
),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: 'admin_change',
|
||||
riskLevel: base.serverName === 'Cloudflare' ? 'critical' : 'high',
|
||||
effectType: 'external_side_effect',
|
||||
resourceKind: 'admin',
|
||||
policyFamily: 'admin_control',
|
||||
requiresConfirmedIntent: true,
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
test: textMatcher(/\b(delete|remove|archive|destroy|purge)\b/i),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: 'delete_record',
|
||||
riskLevel: 'critical',
|
||||
effectType: 'destructive',
|
||||
policyFamily: 'destructive_mutation',
|
||||
requiresConfirmedIntent: true,
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'send-message',
|
||||
test: textMatcher(
|
||||
/\b(send|reply|forward|post message|post reply|send email|send message|dm|direct message|publish)\b/i,
|
||||
),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType:
|
||||
base.resourceKind === 'email' ? 'send_message' : 'post_message',
|
||||
riskLevel:
|
||||
base.serverName === 'WhatsApp' || base.serverName === 'Resend'
|
||||
? 'critical'
|
||||
: 'high',
|
||||
effectType: 'external_side_effect',
|
||||
policyFamily: 'communications',
|
||||
requiresConfirmedIntent: true,
|
||||
supportsDraftMode: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'draft',
|
||||
test: textMatcher(/\b(draft|compose draft|create draft|save draft)\b/i),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: 'draft_create',
|
||||
riskLevel: 'medium',
|
||||
effectType: 'draft_only',
|
||||
resourceKind:
|
||||
base.resourceKind === 'unknown' ? 'draft' : base.resourceKind,
|
||||
policyFamily: 'drafting',
|
||||
requiresConfirmedIntent: false,
|
||||
supportsDraftMode: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'upload-download',
|
||||
test: textMatcher(/\b(upload|attach|import|download|export)\b/i),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: /\b(download|export)\b/i.test(
|
||||
normalizeActionText(base.actionName),
|
||||
)
|
||||
? 'file_download'
|
||||
: 'file_upload',
|
||||
riskLevel: 'medium',
|
||||
effectType: 'external_side_effect',
|
||||
resourceKind: 'file',
|
||||
policyFamily: 'external_mutation',
|
||||
requiresConfirmedIntent: false,
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'create',
|
||||
test: textMatcher(/\b(create|add|insert|open issue|new event|schedule)\b/i),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: 'create_record',
|
||||
riskLevel: base.policyFamily === 'communications' ? 'high' : 'medium',
|
||||
effectType: 'external_side_effect',
|
||||
policyFamily:
|
||||
base.policyFamily === 'unknown'
|
||||
? 'external_mutation'
|
||||
: base.policyFamily,
|
||||
requiresConfirmedIntent: base.policyFamily === 'communications',
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'update',
|
||||
test: textMatcher(/\b(update|edit|modify|move|assign|close|reopen)\b/i),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: 'update_record',
|
||||
riskLevel: 'medium',
|
||||
effectType: 'external_side_effect',
|
||||
policyFamily:
|
||||
base.policyFamily === 'unknown'
|
||||
? 'external_mutation'
|
||||
: base.policyFamily,
|
||||
requiresConfirmedIntent: false,
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'read-search-list',
|
||||
test: textMatcher(
|
||||
/\b(list|get|find|search|fetch|read|retrieve|view|show|lookup|query)\b/i,
|
||||
),
|
||||
apply: (base) =>
|
||||
applyClassification(base, {
|
||||
capabilityType: /\b(search|find|lookup|query)\b/i.test(
|
||||
normalizeActionText(base.actionName),
|
||||
)
|
||||
? 'search'
|
||||
: /\b(list)\b/i.test(normalizeActionText(base.actionName))
|
||||
? 'list'
|
||||
: 'read',
|
||||
riskLevel: base.policyFamily === 'financial' ? 'medium' : 'low',
|
||||
effectType: 'read_only',
|
||||
policyFamily:
|
||||
base.policyFamily === 'financial'
|
||||
? 'financial'
|
||||
: 'read_only_external',
|
||||
requiresConfirmedIntent: false,
|
||||
supportsDraftMode: false,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
export function createUnknownActionClassification(
|
||||
action: KlavisExternalActionRef,
|
||||
): KlavisCapabilityClassification {
|
||||
return {
|
||||
surface: 'external_action',
|
||||
normalizedKey: '',
|
||||
capabilityType: 'unknown',
|
||||
riskLevel: 'unknown',
|
||||
effectType: 'unknown',
|
||||
resourceKind: 'unknown',
|
||||
policyFamily: 'unknown',
|
||||
requiresConfirmedIntent: true,
|
||||
supportsDraftMode: false,
|
||||
serverName: action.serverName,
|
||||
categoryName: action.categoryName,
|
||||
actionName: action.actionName,
|
||||
notes:
|
||||
'Unknown external action; future policy should treat conservatively.',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
KlavisCapabilityClassification,
|
||||
KlavisExternalActionRef,
|
||||
} from '@browseros/shared/types/klavis-classification'
|
||||
import {
|
||||
createUnknownActionClassification,
|
||||
KLAVIS_ACTION_PATTERN_RULES,
|
||||
KLAVIS_SERVER_PROFILES,
|
||||
KLAVIS_STATIC_TOOL_CLASSIFICATIONS,
|
||||
} from './action-classification-registry'
|
||||
import {
|
||||
getGeneratedCatalogEntry,
|
||||
getGeneratedCatalogForServer,
|
||||
} from './generated-tool-catalog'
|
||||
|
||||
export function normalizeKlavisSegment(value: string | undefined): string {
|
||||
return (value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function buildKlavisActionKey(action: KlavisExternalActionRef): string {
|
||||
return [
|
||||
'external_action',
|
||||
normalizeKlavisSegment(action.serverName).replace(/ /g, '_'),
|
||||
normalizeKlavisSegment(action.categoryName).replace(/ /g, '_') || '_',
|
||||
normalizeKlavisSegment(action.actionName).replace(/ /g, '_'),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function classifyKlavisToolName(
|
||||
toolName: string,
|
||||
): KlavisCapabilityClassification {
|
||||
const known = KLAVIS_STATIC_TOOL_CLASSIFICATIONS.get(toolName)
|
||||
if (known) {
|
||||
return known
|
||||
}
|
||||
|
||||
const normalizedToolName = normalizeKlavisSegment(toolName)
|
||||
const matches = []
|
||||
for (const server of KLAVIS_SERVER_PROFILES.keys()) {
|
||||
const entry = getGeneratedCatalogEntry(server, normalizedToolName)
|
||||
if (entry) matches.push(entry)
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
return classifyKlavisExternalAction({
|
||||
serverName: matches[0].serverName,
|
||||
actionName: matches[0].toolName,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
surface: 'strata_tool',
|
||||
normalizedKey: `strata_tool:${toolName}`,
|
||||
toolName,
|
||||
capabilityType: 'unknown',
|
||||
riskLevel: 'unknown',
|
||||
effectType: 'unknown',
|
||||
resourceKind: 'unknown',
|
||||
policyFamily: 'unknown',
|
||||
requiresConfirmedIntent: true,
|
||||
supportsDraftMode: false,
|
||||
notes: 'Unknown Strata tool name; treat as conservative until classified.',
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyKlavisExternalAction(
|
||||
action: KlavisExternalActionRef,
|
||||
): KlavisCapabilityClassification {
|
||||
const normalizedCategory = normalizeKlavisSegment(action.categoryName)
|
||||
const normalizedAction = normalizeKlavisSegment(action.actionName)
|
||||
const generatedEntry = getGeneratedCatalogEntry(
|
||||
action.serverName,
|
||||
normalizedAction,
|
||||
)
|
||||
const generatedCatalog = getGeneratedCatalogForServer(action.serverName)
|
||||
const combinedText = [
|
||||
normalizedCategory,
|
||||
normalizedAction,
|
||||
generatedEntry?.normalizedSearchText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
const profile = KLAVIS_SERVER_PROFILES.get(action.serverName)
|
||||
|
||||
const base = {
|
||||
...createUnknownActionClassification(action),
|
||||
normalizedKey: buildKlavisActionKey(action),
|
||||
serverName: action.serverName,
|
||||
categoryName: action.categoryName,
|
||||
actionName: action.actionName,
|
||||
resourceKind: profile?.resourceKind ?? 'unknown',
|
||||
policyFamily: profile?.defaultPolicyFamily ?? 'unknown',
|
||||
riskLevel: profile?.defaultRiskLevel ?? 'unknown',
|
||||
effectType: profile?.defaultEffectType ?? 'unknown',
|
||||
notes:
|
||||
generatedEntry && generatedCatalog
|
||||
? `Matched generated Klavis catalog for ${generatedCatalog.serverName}.`
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const matchedRule = KLAVIS_ACTION_PATTERN_RULES.find((rule) =>
|
||||
rule.test({
|
||||
profile,
|
||||
normalizedCategory,
|
||||
normalizedAction,
|
||||
combinedText,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!matchedRule) {
|
||||
return base
|
||||
}
|
||||
|
||||
return matchedRule.apply(base)
|
||||
}
|
||||
|
||||
export function summarizeKlavisToolExposure(
|
||||
toolNames: string[],
|
||||
): Record<string, number> {
|
||||
const summary: Record<string, number> = {}
|
||||
for (const toolName of toolNames) {
|
||||
const classification = classifyKlavisToolName(toolName)
|
||||
summary[classification.capabilityType] =
|
||||
(summary[classification.capabilityType] ?? 0) + 1
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import catalog from './generated/server-tool-catalog.json'
|
||||
|
||||
export interface GeneratedKlavisToolCatalogEntry {
|
||||
serverName: string
|
||||
toolName: string
|
||||
description: string
|
||||
inputSchema?: unknown
|
||||
normalizedToolName: string
|
||||
normalizedSearchText: string
|
||||
}
|
||||
|
||||
export interface GeneratedKlavisServerCatalog {
|
||||
serverName: string
|
||||
toolCount: number
|
||||
tools: GeneratedKlavisToolCatalogEntry[]
|
||||
}
|
||||
|
||||
export interface GeneratedKlavisToolCatalog {
|
||||
generatedAt: string | null
|
||||
sourceFormat: string
|
||||
sourceApiBaseUrl: string | null
|
||||
serverCount: number
|
||||
servers: GeneratedKlavisServerCatalog[]
|
||||
}
|
||||
|
||||
export const GENERATED_KLAVIS_TOOL_CATALOG =
|
||||
catalog as GeneratedKlavisToolCatalog
|
||||
|
||||
const catalogByServer = new Map(
|
||||
GENERATED_KLAVIS_TOOL_CATALOG.servers.map((server) => [
|
||||
server.serverName,
|
||||
server,
|
||||
]),
|
||||
)
|
||||
|
||||
export function getGeneratedCatalogForServer(
|
||||
serverName: string,
|
||||
): GeneratedKlavisServerCatalog | undefined {
|
||||
return catalogByServer.get(serverName)
|
||||
}
|
||||
|
||||
export function getGeneratedCatalogEntry(
|
||||
serverName: string,
|
||||
normalizedToolName: string,
|
||||
): GeneratedKlavisToolCatalogEntry | undefined {
|
||||
return getGeneratedCatalogForServer(serverName)?.tools.find(
|
||||
(tool) => tool.normalizedToolName === normalizedToolName,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,19 @@ export interface UserIntegration {
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export type KlavisServerToolsFormat =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'gemini'
|
||||
| 'mcp_native'
|
||||
|
||||
export interface KlavisServerToolsResponse {
|
||||
success: boolean
|
||||
format: KlavisServerToolsFormat | string
|
||||
tools: unknown[] | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export class KlavisClient {
|
||||
private baseUrl: string
|
||||
|
||||
@@ -157,4 +170,61 @@ export class KlavisClient {
|
||||
`/mcp-server/strata/${strata.strataId}/servers?servers=${encodeURIComponent(serverName)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the published tool catalog for a supported Klavis server.
|
||||
* This uses the public Klavis metadata API, not the BrowserOS proxy.
|
||||
*/
|
||||
async getServerTools(
|
||||
serverName: string,
|
||||
format: KlavisServerToolsFormat = 'openai',
|
||||
): Promise<KlavisServerToolsResponse> {
|
||||
const apiKey = process.env.KLAVIS_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'KLAVIS_API_KEY is required to fetch Klavis tool metadata',
|
||||
)
|
||||
}
|
||||
|
||||
const apiBaseUrl =
|
||||
process.env.KLAVIS_API_BASE_URL || 'https://api.klavis.ai'
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
TIMEOUTS.KLAVIS_FETCH,
|
||||
)
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
`${apiBaseUrl.replace(/\/$/, '')}/mcp-server/tools/${encodeURIComponent(serverName)}`,
|
||||
)
|
||||
url.searchParams.set('format', format)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`Klavis metadata error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (await response.json()) as KlavisServerToolsResponse
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Klavis metadata request timed out after ${TIMEOUTS.KLAVIS_FETCH}ms`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
buildKlavisActionKey,
|
||||
classifyKlavisExternalAction,
|
||||
classifyKlavisToolName,
|
||||
normalizeKlavisSegment,
|
||||
summarizeKlavisToolExposure,
|
||||
} from '../../../../src/lib/clients/klavis/action-classifier'
|
||||
import { GENERATED_KLAVIS_TOOL_CATALOG } from '../../../../src/lib/clients/klavis/generated-tool-catalog'
|
||||
|
||||
describe('normalizeKlavisSegment', () => {
|
||||
it('normalizes mixed casing and punctuation', () => {
|
||||
expect(normalizeKlavisSegment('Send Email!')).toBe('send email')
|
||||
expect(normalizeKlavisSegment(' Create_Draft ')).toBe('create draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyKlavisToolName', () => {
|
||||
it('classifies discovery tools as low risk safe meta', () => {
|
||||
const classification = classifyKlavisToolName(
|
||||
'discover_server_categories_or_actions',
|
||||
)
|
||||
expect(classification.capabilityType).toBe('discovery')
|
||||
expect(classification.riskLevel).toBe('low')
|
||||
expect(classification.policyFamily).toBe('safe_meta')
|
||||
expect(classification.effectType).toBe('read_only')
|
||||
})
|
||||
|
||||
it('treats execute_action as a high-risk dispatcher', () => {
|
||||
const classification = classifyKlavisToolName('execute_action')
|
||||
expect(classification.capabilityType).toBe('unknown')
|
||||
expect(classification.riskLevel).toBe('high')
|
||||
expect(classification.requiresConfirmedIntent).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back conservatively for unknown tools', () => {
|
||||
const classification = classifyKlavisToolName('mystery_tool')
|
||||
expect(classification.capabilityType).toBe('unknown')
|
||||
expect(classification.riskLevel).toBe('unknown')
|
||||
expect(classification.policyFamily).toBe('unknown')
|
||||
})
|
||||
|
||||
it('classifies generated catalog tools before fallback heuristics', () => {
|
||||
const classification = classifyKlavisToolName('brave_web_search')
|
||||
expect(classification.serverName).toBe('Brave Search')
|
||||
expect(classification.capabilityType).toBe('search')
|
||||
expect(classification.effectType).toBe('read_only')
|
||||
expect(classification.policyFamily).toBe('read_only_external')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyKlavisExternalAction', () => {
|
||||
it('classifies Gmail send email as high-risk communications', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Gmail',
|
||||
categoryName: 'Messages',
|
||||
actionName: 'Send Email',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('send_message')
|
||||
expect(classification.riskLevel).toBe('high')
|
||||
expect(classification.effectType).toBe('external_side_effect')
|
||||
expect(classification.policyFamily).toBe('communications')
|
||||
expect(classification.supportsDraftMode).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies Slack post message as high-risk communications', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Slack',
|
||||
categoryName: 'Messaging',
|
||||
actionName: 'Post Message',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('post_message')
|
||||
expect(classification.riskLevel).toBe('high')
|
||||
expect(classification.policyFamily).toBe('communications')
|
||||
})
|
||||
|
||||
it('classifies create draft as draft-only', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Gmail',
|
||||
categoryName: 'Messages',
|
||||
actionName: 'Create Draft',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('draft_create')
|
||||
expect(classification.effectType).toBe('draft_only')
|
||||
expect(classification.policyFamily).toBe('drafting')
|
||||
})
|
||||
|
||||
it('classifies Stripe refund charge as critical financial operation', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Stripe',
|
||||
categoryName: 'Payments',
|
||||
actionName: 'Refund Charge',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('financial_operation')
|
||||
expect(classification.riskLevel).toBe('critical')
|
||||
expect(classification.policyFamily).toBe('financial')
|
||||
expect(classification.resourceKind).toBe('payment')
|
||||
})
|
||||
|
||||
it('classifies Notion delete page as destructive mutation', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Notion',
|
||||
categoryName: 'Pages',
|
||||
actionName: 'Delete Page',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('delete_record')
|
||||
expect(classification.effectType).toBe('destructive')
|
||||
expect(classification.policyFamily).toBe('destructive_mutation')
|
||||
expect(classification.riskLevel).toBe('critical')
|
||||
})
|
||||
|
||||
it('classifies Brave Search query as read-only external', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Brave Search',
|
||||
categoryName: 'Web',
|
||||
actionName: 'Search Query',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('search')
|
||||
expect(classification.effectType).toBe('read_only')
|
||||
expect(classification.policyFamily).toBe('read_only_external')
|
||||
expect(classification.riskLevel).toBe('low')
|
||||
})
|
||||
|
||||
it('falls back conservatively for unknown external actions', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Unknown App',
|
||||
categoryName: 'Mystery',
|
||||
actionName: 'Do Stuff',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('unknown')
|
||||
expect(classification.riskLevel).toBe('unknown')
|
||||
expect(classification.effectType).toBe('unknown')
|
||||
expect(classification.policyFamily).toBe('unknown')
|
||||
expect(classification.requiresConfirmedIntent).toBe(true)
|
||||
})
|
||||
|
||||
it('uses generated catalog entries to classify supported server tools', () => {
|
||||
const classification = classifyKlavisExternalAction({
|
||||
serverName: 'Airtable',
|
||||
actionName: 'airtable_create_records',
|
||||
})
|
||||
expect(classification.capabilityType).toBe('create_record')
|
||||
expect(classification.effectType).toBe('external_side_effect')
|
||||
expect(classification.policyFamily).toBe('external_mutation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildKlavisActionKey', () => {
|
||||
it('builds a normalized stable key', () => {
|
||||
expect(
|
||||
buildKlavisActionKey({
|
||||
serverName: 'Google Calendar',
|
||||
categoryName: 'Events',
|
||||
actionName: 'Create Event',
|
||||
}),
|
||||
).toBe('external_action:google_calendar:events:create_event')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeKlavisToolExposure', () => {
|
||||
it('groups tool names by capability type', () => {
|
||||
expect(
|
||||
summarizeKlavisToolExposure([
|
||||
'discover_server_categories_or_actions',
|
||||
'get_action_details',
|
||||
'execute_action',
|
||||
]),
|
||||
).toEqual({
|
||||
discovery: 2,
|
||||
unknown: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GENERATED_KLAVIS_TOOL_CATALOG', () => {
|
||||
it('contains fetched tool metadata for the supported server set', () => {
|
||||
expect(GENERATED_KLAVIS_TOOL_CATALOG.serverCount).toBe(45)
|
||||
expect(GENERATED_KLAVIS_TOOL_CATALOG.generatedAt).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,6 @@
|
||||
"declarationMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*", "package.json"],
|
||||
"include": ["src/**/*", "src/**/*.json", "package.json"],
|
||||
"exclude": ["node_modules", "dist/**/*"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"types": "./src/types/server-config.ts",
|
||||
"default": "./src/types/server-config.ts"
|
||||
},
|
||||
"./types/klavis-classification": {
|
||||
"types": "./src/types/klavis-classification.ts",
|
||||
"default": "./src/types/klavis-classification.ts"
|
||||
},
|
||||
"./schemas/llm": {
|
||||
"types": "./src/schemas/llm.ts",
|
||||
"default": "./src/schemas/llm.ts"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const KLAVIS_CAPABILITY_TYPES = [
|
||||
'discovery',
|
||||
'documentation_lookup',
|
||||
'auth',
|
||||
'read',
|
||||
'search',
|
||||
'list',
|
||||
'draft_create',
|
||||
'draft_update',
|
||||
'send_message',
|
||||
'post_message',
|
||||
'create_record',
|
||||
'update_record',
|
||||
'delete_record',
|
||||
'file_upload',
|
||||
'file_download',
|
||||
'workflow_trigger',
|
||||
'admin_change',
|
||||
'financial_operation',
|
||||
'unknown',
|
||||
] as const
|
||||
|
||||
export type KlavisCapabilityType = (typeof KLAVIS_CAPABILITY_TYPES)[number]
|
||||
|
||||
export const KLAVIS_RISK_LEVELS = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'critical',
|
||||
'unknown',
|
||||
] as const
|
||||
|
||||
export type KlavisRiskLevel = (typeof KLAVIS_RISK_LEVELS)[number]
|
||||
|
||||
export const KLAVIS_EFFECT_TYPES = [
|
||||
'none',
|
||||
'read_only',
|
||||
'draft_only',
|
||||
'external_side_effect',
|
||||
'destructive',
|
||||
'unknown',
|
||||
] as const
|
||||
|
||||
export type KlavisEffectType = (typeof KLAVIS_EFFECT_TYPES)[number]
|
||||
|
||||
export const KLAVIS_RESOURCE_KINDS = [
|
||||
'message',
|
||||
'draft',
|
||||
'email',
|
||||
'calendar_event',
|
||||
'document',
|
||||
'file',
|
||||
'issue',
|
||||
'ticket',
|
||||
'task',
|
||||
'contact',
|
||||
'repository',
|
||||
'payment',
|
||||
'deployment',
|
||||
'analytics',
|
||||
'admin',
|
||||
'unknown',
|
||||
] as const
|
||||
|
||||
export type KlavisResourceKind = (typeof KLAVIS_RESOURCE_KINDS)[number]
|
||||
|
||||
export const KLAVIS_POLICY_FAMILIES = [
|
||||
'safe_meta',
|
||||
'read_only_external',
|
||||
'drafting',
|
||||
'communications',
|
||||
'external_mutation',
|
||||
'destructive_mutation',
|
||||
'admin_control',
|
||||
'financial',
|
||||
'unknown',
|
||||
] as const
|
||||
|
||||
export type KlavisPolicyFamily = (typeof KLAVIS_POLICY_FAMILIES)[number]
|
||||
|
||||
export const KLAVIS_CLASSIFICATION_SURFACES = [
|
||||
'strata_tool',
|
||||
'external_action',
|
||||
] as const
|
||||
|
||||
export type KlavisClassificationSurface =
|
||||
(typeof KLAVIS_CLASSIFICATION_SURFACES)[number]
|
||||
|
||||
export interface KlavisExternalActionRef {
|
||||
serverName: string
|
||||
categoryName?: string
|
||||
actionName: string
|
||||
}
|
||||
|
||||
export interface KlavisCapabilityClassification {
|
||||
surface: KlavisClassificationSurface
|
||||
normalizedKey: string
|
||||
capabilityType: KlavisCapabilityType
|
||||
riskLevel: KlavisRiskLevel
|
||||
effectType: KlavisEffectType
|
||||
resourceKind: KlavisResourceKind
|
||||
policyFamily: KlavisPolicyFamily
|
||||
requiresConfirmedIntent: boolean
|
||||
supportsDraftMode: boolean
|
||||
toolName?: string
|
||||
serverName?: string
|
||||
categoryName?: string
|
||||
actionName?: string
|
||||
notes?: string
|
||||
}
|
||||
125
packages/browseros-agent/scripts/fetch-klavis-tool-catalog.ts
Normal file
125
packages/browseros-agent/scripts/fetch-klavis-tool-catalog.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
KlavisClient,
|
||||
type KlavisServerToolsFormat,
|
||||
} from '../apps/server/src/lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../apps/server/src/lib/clients/klavis/oauth-mcp-servers'
|
||||
|
||||
const OUTPUT_PATH = new URL(
|
||||
'../apps/server/src/lib/clients/klavis/generated/server-tool-catalog.json',
|
||||
import.meta.url,
|
||||
).pathname
|
||||
|
||||
type OpenAITool = {
|
||||
type?: string
|
||||
function?: {
|
||||
name?: string
|
||||
description?: string
|
||||
parameters?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface GeneratedKlavisToolCatalogEntry {
|
||||
serverName: string
|
||||
toolName: string
|
||||
description: string
|
||||
inputSchema?: unknown
|
||||
normalizedToolName: string
|
||||
normalizedSearchText: string
|
||||
}
|
||||
|
||||
interface GeneratedKlavisServerCatalog {
|
||||
serverName: string
|
||||
toolCount: number
|
||||
tools: GeneratedKlavisToolCatalogEntry[]
|
||||
}
|
||||
|
||||
interface GeneratedKlavisToolCatalog {
|
||||
generatedAt: string | null
|
||||
sourceFormat: KlavisServerToolsFormat
|
||||
sourceApiBaseUrl: string | null
|
||||
serverCount: number
|
||||
servers: GeneratedKlavisServerCatalog[]
|
||||
}
|
||||
|
||||
function normalizeText(value: string | undefined): string {
|
||||
return (value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeOpenAITools(
|
||||
serverName: string,
|
||||
tools: unknown[] | null,
|
||||
): GeneratedKlavisServerCatalog {
|
||||
const normalizedTools = (Array.isArray(tools) ? tools : [])
|
||||
.map((tool) => tool as OpenAITool)
|
||||
.map((tool) => {
|
||||
const toolName = tool.function?.name?.trim() ?? ''
|
||||
if (!toolName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const description = tool.function?.description?.trim() ?? ''
|
||||
const normalizedToolName = normalizeText(toolName)
|
||||
|
||||
return {
|
||||
serverName,
|
||||
toolName,
|
||||
description,
|
||||
inputSchema: tool.function?.parameters,
|
||||
normalizedToolName,
|
||||
normalizedSearchText: normalizeText(`${toolName} ${description}`),
|
||||
} satisfies GeneratedKlavisToolCatalogEntry
|
||||
})
|
||||
.filter((entry): entry is GeneratedKlavisToolCatalogEntry => entry !== null)
|
||||
.sort((a, b) => a.toolName.localeCompare(b.toolName))
|
||||
|
||||
return {
|
||||
serverName,
|
||||
toolCount: normalizedTools.length,
|
||||
tools: normalizedTools,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const format: KlavisServerToolsFormat = 'openai'
|
||||
const client = new KlavisClient()
|
||||
const apiBaseUrl = process.env.KLAVIS_API_BASE_URL || 'https://api.klavis.ai'
|
||||
|
||||
console.log(
|
||||
`Fetching Klavis tool catalogs for ${OAUTH_MCP_SERVERS.length} supported servers...`,
|
||||
)
|
||||
|
||||
const servers: GeneratedKlavisServerCatalog[] = []
|
||||
|
||||
for (const server of OAUTH_MCP_SERVERS) {
|
||||
console.log(`- ${server.name}`)
|
||||
const response = await client.getServerTools(server.name, format)
|
||||
if (!response.success) {
|
||||
throw new Error(
|
||||
`Klavis metadata fetch failed for ${server.name}: ${response.error ?? 'unknown error'}`,
|
||||
)
|
||||
}
|
||||
servers.push(normalizeOpenAITools(server.name, response.tools))
|
||||
}
|
||||
|
||||
servers.sort((a, b) => a.serverName.localeCompare(b.serverName))
|
||||
|
||||
const output: GeneratedKlavisToolCatalog = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceFormat: format,
|
||||
sourceApiBaseUrl: apiBaseUrl,
|
||||
serverCount: servers.length,
|
||||
servers,
|
||||
}
|
||||
|
||||
await Bun.write(OUTPUT_PATH, `${JSON.stringify(output, null, 2)}\n`)
|
||||
console.log(`Written ${servers.length} server catalogs to ${OUTPUT_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user