Compare commits

..

1 Commits

Author SHA1 Message Date
shivammittal274
9d9cc97016 feat(cli): add strata commands for Klavis MCP integrations
Expose the 7 Klavis Strata MCP tools as CLI subcommands under
`browseros-cli strata`, so CLI users (claude-code, gemini-cli) can
discover and execute actions on 40+ external services.

Commands: check, discover, actions, details, exec, search, auth.
Includes discovery flow guidance in help text, integration tests,
and an "Integrations:" group in the root help output.
2026-04-14 15:29:26 +05:30
131 changed files with 264 additions and 13604 deletions

View File

@@ -4,7 +4,6 @@ on:
pull_request:
branches:
- main
- dev
paths:
- "packages/browseros-agent/**"

1
.gitignore vendored
View File

@@ -23,7 +23,6 @@ nxtscape-cli-access.json
gclient.json
.env
.grove/
AGENTS.md
**/resources/binaries/
packages/browseros/build/tools/

View File

@@ -1,4 +1,3 @@
CLAUDE.md
# Logs
logs
*.log

View File

@@ -148,7 +148,7 @@ When creating new packages in this monorepo:
## Test Organization
Tests are in `apps/server/tests/`:
- `tools/` - Tool tests (require BrowserOS running with CDP), plus ACL scorer tests (standalone)
- `tools/` - Tool tests (require BrowserOS running with CDP)
- `browser/` - Browser backend tests
- `agent/` - Agent tests (compaction, rate limiter)
- `sdk/` - Agent SDK tests

View File

@@ -1,143 +0,0 @@
import {
CheckCircle2,
ChevronDown,
CircleDotDashed,
Clock3,
ShieldAlert,
ShieldCheck,
XCircle,
} from 'lucide-react'
import { type FC, useState } from 'react'
import { ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import type { ExecutionStepRecord } from '@/lib/execution-history/types'
import { cn } from '@/lib/utils'
const formatToolName = (name: string) =>
name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, (value) => value.toUpperCase())
const formatStateLabel = (state: ExecutionStepRecord['state']) => {
if (state === 'input-streaming') return 'Preparing'
if (state === 'input-available') return 'Running'
if (state === 'approval-requested') return 'Approval Needed'
if (state === 'approval-responded') return 'Approval Responded'
if (state === 'output-available') return 'Completed'
if (state === 'output-denied') return 'Denied'
return 'Error'
}
const getStateIcon = (step: ExecutionStepRecord) => {
if (step.state === 'output-available') {
return <CheckCircle2 className="h-4 w-4 text-green-500" />
}
if (
step.state === 'input-streaming' ||
step.state === 'input-available' ||
step.state === 'approval-requested'
) {
return <Clock3 className="h-4 w-4 text-[var(--accent-orange)]" />
}
if (step.state === 'approval-responded') {
return <ShieldCheck className="h-4 w-4 text-blue-500" />
}
if (step.state === 'output-denied') {
return <ShieldAlert className="h-4 w-4 text-orange-500" />
}
if (step.state === 'output-error') {
return <XCircle className="h-4 w-4 text-destructive" />
}
return <CircleDotDashed className="h-4 w-4 text-muted-foreground" />
}
const isAclBlocked = (step: ExecutionStepRecord) =>
Boolean(
step.errorText?.includes('Action blocked by ACL rule') ||
step.approval?.reason?.includes('Action blocked by ACL rule') ||
step.previewText === 'Blocked by ACL rule',
)
const shouldShowPreview = (step: ExecutionStepRecord) =>
step.state === 'input-streaming' ||
step.state === 'input-available' ||
step.state === 'approval-requested' ||
step.state === 'approval-responded'
export const ExecutionStepItem: FC<{
step: ExecutionStepRecord
defaultOpen?: boolean
}> = ({ step, defaultOpen = false }) => {
const [open, setOpen] = useState(defaultOpen)
const deniedReason =
step.state === 'output-denied' ? step.approval?.reason : undefined
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-xl border border-border/60 bg-card/60">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-start gap-3 px-4 py-3 text-left"
>
<div className="mt-0.5 shrink-0">{getStateIcon(step)}</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-foreground text-sm">
{formatToolName(step.toolName)}
</p>
<Badge variant="secondary">
{formatStateLabel(step.state)}
</Badge>
{isAclBlocked(step) && (
<Badge variant="outline">ACL Blocked</Badge>
)}
</div>
{shouldShowPreview(step) && (
<p className="mt-1 text-muted-foreground text-xs">
{step.previewText}
</p>
)}
</div>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
open && 'rotate-180',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 border-t">
{step.input !== undefined && <ToolInput input={step.input} />}
{step.state === 'output-denied' ? (
<div className="space-y-2 p-4">
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Result
</h4>
<div className="rounded-md bg-orange-500/10 p-3 text-orange-700 text-sm dark:text-orange-300">
{deniedReason ?? 'The requested action was denied.'}
</div>
</div>
) : (
<ToolOutput
output={step.output}
errorText={step.errorText}
className="pt-0"
/>
)}
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -1,168 +0,0 @@
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
CheckCircle2,
ChevronDown,
CircleDot,
CircleSlash2,
MessageSquareText,
Trash2,
XCircle,
} from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
import { cn } from '@/lib/utils'
import { ExecutionStepItem } from './ExecutionStepItem'
dayjs.extend(relativeTime)
dayjs.extend(duration)
function getTaskStatusIcon(status: ExecutionTaskRecord['status']) {
if (status === 'completed') {
return <CheckCircle2 className="h-4 w-4 text-green-500" />
}
if (status === 'running') {
return <CircleDot className="h-4 w-4 text-[var(--accent-orange)]" />
}
if (status === 'stopped') {
return <CircleSlash2 className="h-4 w-4 text-orange-500" />
}
return <XCircle className="h-4 w-4 text-destructive" />
}
function getTaskStatusLabel(status: ExecutionTaskRecord['status']) {
if (status === 'completed') return 'Completed'
if (status === 'running') return 'Running'
if (status === 'stopped') return 'Stopped'
if (status === 'interrupted') return 'Interrupted'
return 'Failed'
}
function formatDuration(task: ExecutionTaskRecord): string | null {
if (!task.completedAt) return null
const diff = dayjs(task.completedAt).diff(task.startedAt)
const parsed = dayjs.duration(diff)
const minutes = Math.floor(parsed.asMinutes())
const seconds = parsed.seconds()
if (minutes === 0) return `${seconds}s`
return `${minutes}m ${seconds}s`
}
export const ExecutionTaskCard: FC<{
task: ExecutionTaskRecord
defaultOpen?: boolean
onDelete?: (task: ExecutionTaskRecord) => void
}> = ({ task, defaultOpen = false, onDelete }) => {
const [open, setOpen] = useState(defaultOpen)
const startedAgo = useMemo(
() => dayjs(task.startedAt).fromNow(),
[task.startedAt],
)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-2xl border border-border/60 bg-card shadow-sm">
<div className="flex items-start gap-2 px-5 py-5">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-3 text-left"
>
<div className="mt-0.5 shrink-0">
{getTaskStatusIcon(task.status)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="line-clamp-2 font-medium text-base text-foreground">
{task.promptText}
</p>
<Badge variant="secondary">
{getTaskStatusLabel(task.status)}
</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<span>{startedAgo}</span>
<span></span>
<span>
{task.actionCount} action{task.actionCount === 1 ? '' : 's'}
</span>
{formatDuration(task) && (
<>
<span></span>
<span>{formatDuration(task)}</span>
</>
)}
{task.deniedCount > 0 && (
<Badge variant="outline" className="h-5 rounded-full px-2">
{task.deniedCount} denied
</Badge>
)}
{task.errorCount > 0 && (
<Badge variant="outline" className="h-5 rounded-full px-2">
{task.errorCount} error
{task.errorCount === 1 ? '' : 's'}
</Badge>
)}
</div>
{task.responsePreview ? (
<div className="mt-4 flex items-start gap-2 rounded-xl bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
<MessageSquareText className="mt-0.5 h-4 w-4 shrink-0" />
<p className="line-clamp-2">{task.responsePreview}</p>
</div>
) : null}
</div>
<ChevronDown
className={cn(
'mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
open && 'rotate-180',
)}
/>
</button>
</CollapsibleTrigger>
{onDelete ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onDelete(task)}
aria-label={`Delete ${task.promptText}`}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
<CollapsibleContent className="border-border/60 border-t px-5 py-5">
{task.steps.length === 0 ? (
<div className="rounded-xl border border-border/70 border-dashed bg-muted/30 px-4 py-6 text-center text-muted-foreground text-sm">
No tool actions were recorded for this task.
</div>
) : (
<div className="space-y-3">
{task.steps.map((step, index) => (
<ExecutionStepItem
key={step.id}
step={step}
defaultOpen={
task.status === 'running' && index === task.steps.length - 1
}
/>
))}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -9,8 +9,6 @@ import {
RotateCcw,
Search,
Server,
ShieldAlert,
ShieldCheck,
} from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
@@ -80,9 +78,7 @@ const primarySettingsSections: NavSection[] = [
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'Tool Approvals', to: '/settings/approvals', icon: ShieldCheck },
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{ name: 'ACL Rules', to: '/settings/acl', icon: ShieldAlert },
{
name: 'Usage & Billing',
to: '/settings/usage',

View File

@@ -1,11 +1,9 @@
import {
Brain,
CalendarClock,
Cpu,
Home,
PlugZap,
Settings,
Shield,
Sparkles,
Wand2,
} from 'lucide-react'
@@ -41,7 +39,6 @@ const primaryNavItems: NavItem[] = [
feature: Feature.MANAGED_MCP_SUPPORT,
},
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
{ name: 'Agents', to: '/agents', icon: Cpu },
{
name: 'Skills',
to: '/home/skills',
@@ -60,7 +57,6 @@ const primaryNavItems: NavItem[] = [
icon: Sparkles,
feature: Feature.SOUL_SUPPORT,
},
{ name: 'Governance', to: '/admin', icon: Shield },
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]

View File

@@ -1,5 +1,7 @@
import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import { NewTab } from '../newtab/index/NewTab'
import { NewTabChat } from '../newtab/index/NewTabChat'
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
import { Personalize } from '../newtab/personalize/Personalize'
@@ -7,12 +9,6 @@ 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 { AclSettingsPage } from './acl-settings/AclSettingsPage'
import { AdminDashboardPage } from './admin-dashboard/AdminDashboardPage'
import { AgentCommandConversation } from './agent-command/AgentCommandConversation'
import { AgentCommandHome } from './agent-command/AgentCommandHome'
import { AgentCommandLayout } from './agent-command/agent-command-layout'
import { AgentsPage } from './agents/AgentsPage'
import { AISettingsPage } from './ai-settings/AISettingsPage'
import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { CustomizationPage } from './customization/CustomizationPage'
@@ -31,7 +27,6 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { ToolApprovalsPage } from './tool-approvals/ToolApprovalsPage'
import { UsagePage } from './usage/UsagePage'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
@@ -81,13 +76,7 @@ export const App: FC = () => {
<Route element={<SidebarLayout />}>
{/* Home routes */}
<Route path="home" element={<NewTabLayout />}>
<Route element={<AgentCommandLayout />}>
<Route index element={<AgentCommandHome />} />
<Route
path="agents/:agentId"
element={<AgentCommandConversation />}
/>
</Route>
<Route index element={<NewTab />} />
<Route path="chat" element={<NewTabChat />} />
<Route path="personalize" element={<Personalize />} />
<Route path="soul" element={<SoulPage />} />
@@ -98,8 +87,6 @@ export const App: FC = () => {
{/* Primary nav routes */}
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="admin" element={<AdminDashboardPage />} />
</Route>
{/* Settings with dedicated sidebar */}
@@ -113,8 +100,6 @@ export const App: FC = () => {
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
<Route path="acl" element={<AclSettingsPage />} />
<Route path="approvals" element={<ToolApprovalsPage />} />
</Route>
</Route>
@@ -144,12 +129,6 @@ export const App: FC = () => {
path="/settings/skills"
element={<Navigate to="/home/skills" replace />}
/>
<Route path="/audit" element={<Navigate to="/admin" replace />} />
<Route
path="/observability"
element={<Navigate to="/admin" replace />}
/>
<Route path="/executions" element={<Navigate to="/admin" replace />} />
<Route path="/options/*" element={<OptionsRedirect />} />
{/* Fallback to home */}

View File

@@ -1,57 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { Globe, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
interface AclRuleCardProps {
rule: AclRule
onToggle: (id: string, enabled: boolean) => void
onDelete: (id: string) => void
}
export const AclRuleCard: FC<AclRuleCardProps> = ({
rule,
onToggle,
onDelete,
}) => {
const summary =
rule.description ?? rule.textMatch ?? rule.selector ?? 'Block actions'
return (
<div
className={cn(
'flex items-center gap-4 rounded-xl border p-4 transition-all',
rule.enabled
? 'border-red-300 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20'
: 'border-border bg-card opacity-60',
)}
>
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => onToggle(rule.id, checked)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-sm">{summary}</span>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1 font-mono text-xs">
<Globe className="size-3" />
{rule.sitePattern}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(rule.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
)
}

View File

@@ -1,85 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { Plus, ShieldAlert } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { aclRulesStorage } from '@/lib/acl/storage'
import { AclRuleCard } from './AclRuleCard'
import { NewAclRuleDialog } from './NewAclRuleDialog'
export const AclSettingsPage: FC = () => {
const [rules, setRules] = useState<AclRule[]>([])
useEffect(() => {
aclRulesStorage.getValue().then(setRules)
const unwatch = aclRulesStorage.watch(setRules)
return () => unwatch()
}, [])
const saveRules = (next: AclRule[]) => {
setRules(next)
aclRulesStorage.setValue(next)
}
const handleAddRule = (rule: AclRule) => {
saveRules([...rules, rule])
}
const handleToggle = (id: string, enabled: boolean) => {
saveRules(rules.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
const handleDelete = (id: string) => {
saveRules(rules.filter((r) => r.id !== id))
}
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-semibold text-xl">ACL Rules</h1>
<p className="mt-1 text-muted-foreground text-sm">
Describe what the agent should avoid on a site and BrowserOS will
block matching actions.
</p>
</div>
<NewAclRuleDialog onSave={handleAddRule}>
<Button size="sm">
<Plus className="mr-1 size-4" />
Add Rule
</Button>
</NewAclRuleDialog>
</div>
{rules.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-12 text-center">
<ShieldAlert className="size-10 text-muted-foreground" />
<div>
<p className="font-medium">No ACL rules defined</p>
<p className="mt-1 text-muted-foreground text-sm">
Add a plain-English rule like &ldquo;payments and checkout&rdquo;
or &ldquo;send email&rdquo; and BrowserOS will apply broad safety
blocking on that site.
</p>
</div>
<NewAclRuleDialog onSave={handleAddRule}>
<Button variant="outline" size="sm">
<Plus className="mr-1 size-4" />
Add your first rule
</Button>
</NewAclRuleDialog>
</div>
) : (
<div className="flex flex-col gap-3">
{rules.map((rule) => (
<AclRuleCard
key={rule.id}
rule={rule}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,98 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface NewAclRuleDialogProps {
onSave: (rule: AclRule) => void
children: React.ReactNode
}
export const NewAclRuleDialog: FC<NewAclRuleDialogProps> = ({
onSave,
children,
}) => {
const [open, setOpen] = useState(false)
const [sitePattern, setSitePattern] = useState('')
const [intent, setIntent] = useState('')
const reset = () => {
setSitePattern('')
setIntent('')
}
const handleSave = () => {
if (!sitePattern.trim() || !intent.trim()) return
onSave({
id: crypto.randomUUID(),
sitePattern: sitePattern.trim(),
description: intent.trim(),
enabled: true,
})
reset()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add ACL Rule</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="site-pattern">
Domain <span className="text-destructive">*</span>
</Label>
<Input
id="site-pattern"
placeholder="amazon.com"
value={sitePattern}
onChange={(e) => setSitePattern(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
Matches the domain and all subdomains.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="intent">
What should BrowserOS block?{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="intent"
placeholder="Payments and checkout"
value={intent}
onChange={(e) => setIntent(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
Use plain English. BrowserOS will block matching actions on this
site.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!sitePattern.trim() || !intent.trim()}
>
Add Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,41 +0,0 @@
import { Shield } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
interface AdminDashboardHeaderProps {
pendingCount: number
runningCount: number
}
export const AdminDashboardHeader: FC<AdminDashboardHeaderProps> = ({
pendingCount,
runningCount,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Shield className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-xl">Governance</h2>
{pendingCount > 0 && (
<Badge className="gap-1.5 rounded-full bg-yellow-500/10 text-yellow-600">
{pendingCount} pending
</Badge>
)}
{runningCount > 0 && (
<Badge className="gap-1.5 rounded-full">
{runningCount} live
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
Control agent permissions and audit every action.
</p>
</div>
</div>
</div>
)
}

View File

@@ -1,199 +0,0 @@
import dayjs from 'dayjs'
import { Shield } from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { ExecutionTaskCard } from '@/components/execution-history/ExecutionTaskCard'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
removeConversationExecutionTask,
useExecutionHistoryByConversation,
} from '@/lib/execution-history/storage'
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
import { pendingToolApprovalsStorage } from '@/lib/tool-approvals/approval-sync-storage'
import { AdminDashboardHeader } from './AdminDashboardHeader'
import { PendingApprovals } from './PendingApprovals'
type TaskGroup = {
label: string
tasks: ExecutionTaskRecord[]
}
function getGroupLabel(date: string) {
const startedAt = dayjs(date)
if (startedAt.isSame(dayjs(), 'day')) return 'Today'
if (startedAt.isSame(dayjs().subtract(1, 'day'), 'day')) return 'Yesterday'
return startedAt.format('MMMM D, YYYY')
}
function groupTasks(tasks: ExecutionTaskRecord[]): TaskGroup[] {
const grouped = new Map<string, ExecutionTaskRecord[]>()
for (const task of tasks) {
const label = getGroupLabel(task.startedAt)
const existing = grouped.get(label) ?? []
grouped.set(label, [...existing, task])
}
return Array.from(grouped.entries()).map(([label, groupItems]) => ({
label,
tasks: groupItems,
}))
}
export const AdminDashboardPage: FC = () => {
const [pendingCount, setPendingCount] = useState(0)
const historyByConversation = useExecutionHistoryByConversation()
const [taskToDelete, setTaskToDelete] = useState<ExecutionTaskRecord | null>(
null,
)
useEffect(() => {
pendingToolApprovalsStorage
.getValue()
.then((v) => setPendingCount(v.length))
const unwatch = pendingToolApprovalsStorage.watch((v) =>
setPendingCount(v.length),
)
return () => unwatch()
}, [])
const historyList = useMemo(
() => Object.values(historyByConversation),
[historyByConversation],
)
const tasks = useMemo(() => {
return historyList
.flatMap((history) => history.tasks)
.sort(
(left, right) =>
new Date(right.startedAt).getTime() -
new Date(left.startedAt).getTime(),
)
}, [historyList])
const groupedTasks = useMemo(() => groupTasks(tasks), [tasks])
const runningCount = useMemo(
() => tasks.filter((task) => task.status === 'running').length,
[tasks],
)
const conversationCount = historyList.length
const handleDeleteTask = async () => {
if (!taskToDelete) return
try {
await removeConversationExecutionTask({
conversationId: taskToDelete.conversationId,
taskId: taskToDelete.id,
})
toast.success('Run removed')
} catch {
toast.error('Failed to remove run')
} finally {
setTaskToDelete(null)
}
}
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<AdminDashboardHeader
pendingCount={pendingCount}
runningCount={runningCount}
/>
<section className="space-y-3">
<h3 className="font-semibold text-sm">Approvals</h3>
<PendingApprovals />
</section>
<section className="space-y-4">
<div>
<h3 className="font-semibold text-sm">Audit Trail</h3>
{tasks.length > 0 && (
<p className="mt-1 text-muted-foreground text-sm">
{tasks.length} recorded run{tasks.length === 1 ? '' : 's'}
{conversationCount > 1
? ` across ${conversationCount} chats`
: ''}
. Newest first.
</p>
)}
</div>
{tasks.length === 0 ? (
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
<Shield className="size-5 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-medium text-lg">No agent runs yet</h3>
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
Run a task in BrowserOS and the execution history will appear
here.
</p>
</div>
) : (
<div className="space-y-6">
{groupedTasks.map((group, groupIndex) => (
<section key={group.label} className="space-y-3">
<div className="flex items-center gap-3">
<h4 className="font-medium text-muted-foreground text-xs">
{group.label}
</h4>
<div className="h-px flex-1 bg-border/60" />
<span className="text-muted-foreground text-xs">
{group.tasks.length} run
{group.tasks.length === 1 ? '' : 's'}
</span>
</div>
<div className="space-y-3">
{group.tasks.map((task, index) => (
<ExecutionTaskCard
key={task.id}
task={task}
defaultOpen={
task.status === 'running' ||
(groupIndex === 0 && index === 0)
}
onDelete={setTaskToDelete}
/>
))}
</div>
</section>
))}
</div>
)}
</section>
<AlertDialog
open={taskToDelete !== null}
onOpenChange={(open) => !open && setTaskToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Run</AlertDialogTitle>
<AlertDialogDescription>
Remove "{taskToDelete?.promptText}" from local history? This only
clears the recorded run on this device.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteTask}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,103 +0,0 @@
import { Clock, ShieldCheck, ShieldX } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
type ApprovalResponse,
approvalResponsesStorage,
type PendingApproval,
pendingToolApprovalsStorage,
queueApprovalResponse,
} from '@/lib/tool-approvals/approval-sync-storage'
const formatToolName = (name: string) =>
name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, (s) => s.toUpperCase())
export const PendingApprovals: FC = () => {
const [pending, setPending] = useState<PendingApproval[]>([])
useEffect(() => {
pendingToolApprovalsStorage.getValue().then(setPending)
const unwatch = pendingToolApprovalsStorage.watch(setPending)
return () => unwatch()
}, [])
const respond = async (approvalId: string, approved: boolean) => {
const response: ApprovalResponse = {
approvalId,
approved,
timestamp: Date.now(),
}
const existing = (await approvalResponsesStorage.getValue()) ?? []
await approvalResponsesStorage.setValue(
queueApprovalResponse(existing, response),
)
}
if (pending.length === 0) {
return (
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
<ShieldCheck className="size-5 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-medium text-lg">No pending approvals</h3>
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
When the agent needs permission to execute a tool, approval requests
will appear here.
</p>
</div>
)
}
return (
<div className="space-y-3">
{pending.map((item) => (
<div
key={item.approvalId}
className="flex items-start gap-4 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4"
>
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-yellow-500/10">
<Clock className="size-4 text-yellow-600" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{formatToolName(item.toolName)}
</span>
<Badge variant="outline" className="text-[10px]">
awaiting
</Badge>
</div>
{Object.keys(item.input).length > 0 && (
<pre className="mt-1 max-h-20 overflow-auto rounded bg-muted/50 p-2 font-mono text-muted-foreground text-xs">
{JSON.stringify(item.input, null, 2)}
</pre>
)}
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="h-7 gap-1 px-3 text-xs"
onClick={() => respond(item.approvalId, true)}
>
<ShieldCheck className="size-3" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-3 text-xs"
onClick={() => respond(item.approvalId, false)}
>
<ShieldX className="size-3" />
Deny
</Button>
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -1,114 +0,0 @@
import { Bot } from 'lucide-react'
import type { FC } from 'react'
import type { AgentCardData } from '@/lib/agent-conversations/types'
import { cn } from '@/lib/utils'
interface AgentCardProps {
agent: AgentCardData
onClick: () => void
active?: boolean
}
function formatTimestamp(timestamp?: number): string {
if (!timestamp) return 'No activity yet'
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function getStatusLabel(status: AgentCardData['status']): string {
if (status === 'working') return 'Working'
if (status === 'error') return 'Error'
return 'Ready'
}
function getStatusTone(status: AgentCardData['status']): string {
if (status === 'working') return 'bg-amber-500'
if (status === 'error') return 'bg-destructive'
return 'bg-emerald-500'
}
export const AgentCardExpanded: FC<AgentCardProps> = ({
agent,
onClick,
active,
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border p-4 text-left shadow-sm transition-all duration-200',
active
? 'border-border/80 bg-card shadow-md ring-1 ring-[var(--accent-orange)]/20'
: 'border-border/60 bg-card/85 hover:border-border hover:bg-card hover:shadow-md',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-xl',
active
? 'bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]'
: 'bg-muted text-muted-foreground',
)}
>
<Bot className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">{agent.name}</div>
<div className="truncate text-muted-foreground text-xs">
{agent.model ?? 'OpenClaw agent'}
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
<span
className={cn('size-2 rounded-full', getStatusTone(agent.status))}
/>
<span>{getStatusLabel(agent.status)}</span>
</div>
</div>
<div className="mt-4 flex-1">
<p className="line-clamp-2 text-foreground/90 text-sm">
{agent.lastMessage ??
'Start a conversation to see recent work and summaries.'}
</p>
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-muted-foreground text-xs">
<span>{formatTimestamp(agent.lastMessageTimestamp)}</span>
<span>Open conversation</span>
</div>
</button>
)
export const AgentCardCompact: FC<AgentCardProps> = ({
agent,
onClick,
active,
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors',
active
? 'border-border bg-card shadow-sm ring-1 ring-[var(--accent-orange)]/20'
: 'border-border/60 bg-card/85 text-foreground hover:border-border hover:bg-card',
)}
>
<span
className={cn(
'size-2 rounded-full',
active ? 'bg-[var(--accent-orange)]' : getStatusTone(agent.status),
)}
/>
<span className="truncate">{agent.name}</span>
</button>
)

View File

@@ -1,71 +0,0 @@
import { Plus } from 'lucide-react'
import type { FC } from 'react'
import type { AgentCardData } from '@/lib/agent-conversations/types'
import { cn } from '@/lib/utils'
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
interface AgentCardDockProps {
agents: AgentCardData[]
activeAgentId?: string
onSelectAgent: (agentId: string) => void
onCreateAgent?: () => void
compact?: boolean
}
function CreateAgentButton({
compact,
onCreateAgent,
}: {
compact?: boolean
onCreateAgent: () => void
}) {
return (
<button
type="button"
onClick={onCreateAgent}
className={cn(
'flex shrink-0 items-center justify-center gap-2 border border-dashed text-muted-foreground transition-colors hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
compact
? 'rounded-full px-3 py-2 text-sm'
: 'min-h-32 rounded-2xl px-5 py-4',
)}
>
<Plus className={compact ? 'size-3.5' : 'size-5'} />
<span>{compact ? 'New' : 'Create agent'}</span>
</button>
)
}
export const AgentCardDock: FC<AgentCardDockProps> = ({
agents,
activeAgentId,
onSelectAgent,
onCreateAgent,
compact,
}) => {
if (agents.length === 0 && !onCreateAgent) return null
const Card = compact ? AgentCardCompact : AgentCardExpanded
return (
<div
className={cn(
compact
? 'flex items-center gap-2 overflow-x-auto pb-1'
: 'grid gap-4 md:grid-cols-3',
)}
>
{agents.map((agent) => (
<Card
key={agent.agentId}
agent={agent}
active={agent.agentId === activeAgentId}
onClick={() => onSelectAgent(agent.agentId)}
/>
))}
{onCreateAgent ? (
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
) : null}
</div>
)
}

View File

@@ -1,194 +0,0 @@
import { Bot, Home, RotateCcw } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { cn } from '@/lib/utils'
import { useAgentCommandData } from './agent-command-layout'
import { ConversationInput } from './ConversationInput'
import { ConversationMessage } from './ConversationMessage'
import { useAgentConversation } from './useAgentConversation'
function ConversationHeader({
agentName,
status,
onGoHome,
onReset,
}: {
agentName: string
status: string
onGoHome: () => void
onReset: () => void
}) {
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
className="rounded-xl"
title="Back to home"
>
<Home className="size-4" />
</Button>
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-5" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">{agentName}</div>
<div className="truncate text-muted-foreground text-sm">
{status}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="rounded-xl text-muted-foreground"
>
<RotateCcw className="mr-2 size-4" />
New conversation
</Button>
</div>
</div>
)
}
function EmptyConversationState({ agentName }: { agentName: string }) {
return (
<div className="flex min-h-full items-center justify-center py-10">
<div className="max-w-md rounded-[1.5rem] border border-border/60 bg-card/90 px-8 py-10 text-center shadow-sm backdrop-blur">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-6" />
</div>
<h2 className="mt-4 font-semibold text-lg">{agentName}</h2>
<p className="mt-2 text-muted-foreground text-sm">
Send a message to start a focused conversation with this agent.
</p>
</div>
</div>
)
}
function getConversationStatusCopy(
status: string | undefined,
streaming: boolean,
): string {
if (streaming) return 'Working on your request'
if (status === 'running') return 'Ready for the next task'
if (status === 'starting') return 'Connecting to OpenClaw'
if (status === 'error') return 'OpenClaw needs attention'
if (status === 'stopped') return 'OpenClaw is offline'
return 'Open agent setup to continue'
}
export const AgentCommandConversation: FC = () => {
const { agentId } = useParams<{ agentId: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const scrollRef = useRef<HTMLDivElement>(null)
const initialQuerySent = useRef(false)
const { status, agents } = useAgentCommandData()
const shouldRedirectHome = !agentId
const resolvedAgentId = agentId ?? ''
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
const agentName = agent?.name || resolvedAgentId || 'Agent'
const { turns, streaming, loading, send, resetConversation } =
useAgentConversation(resolvedAgentId, agentName)
const lastTurn = turns[turns.length - 1]
const lastTurnPartCount = lastTurn?.parts.length ?? 0
useEffect(() => {
if (shouldRedirectHome) return
const query = searchParams.get('q')
if (query && !initialQuerySent.current && !loading) {
initialQuerySent.current = true
setSearchParams({}, { replace: true })
void send(query)
}
}, [loading, searchParams, send, setSearchParams, shouldRedirectHome])
useEffect(() => {
if (
shouldRedirectHome ||
(turns.length === 0 && lastTurnPartCount === 0 && !streaming)
) {
return
}
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
})
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
if (shouldRedirectHome) {
return <Navigate to="/home" replace />
}
const handleSelectAgent = (entry: AgentEntry) => {
navigate(`/home/agents/${entry.agentId}`)
}
const statusCopy = getConversationStatusCopy(status?.status, streaming)
return (
<div className="absolute inset-0 overflow-hidden">
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
<ConversationHeader
agentName={agentName}
status={statusCopy}
onGoHome={() => navigate('/home')}
onReset={resetConversation}
/>
<main
ref={scrollRef}
className={cn(
'styled-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-[1.5rem] border border-border/50 bg-card/85 px-5 py-5 shadow-sm',
'[&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
)}
>
{loading ? (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading conversation...
</div>
) : turns.length === 0 ? (
<EmptyConversationState agentName={agentName} />
) : (
<div className="w-full space-y-4">
{turns.map((turn, index) => (
<ConversationMessage
key={turn.id}
turn={turn}
streaming={streaming && index === turns.length - 1}
/>
))}
</div>
)}
</main>
<div className="w-full flex-shrink-0">
<ConversationInput
variant="conversation"
agents={agents}
selectedAgentId={resolvedAgentId}
onSelectAgent={handleSelectAgent}
onSend={(text) => {
void send(text)
}}
onCreateAgent={() => navigate('/agents')}
streaming={streaming}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={`Message ${agentName}...`}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,182 +0,0 @@
import { ArrowRight } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
import { ScheduleResults } from '@/entrypoints/newtab/index/ScheduleResults'
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
import { TopSites } from '@/entrypoints/newtab/index/TopSites'
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
import { AgentCardDock } from './AgentCardDock'
import { useAgentCommandData } from './agent-command-layout'
import { ConversationInput } from './ConversationInput'
import { useAgentCardData } from './useAgentCardData'
function AgentCommandSetupState({
onOpenAgents,
}: {
onOpenAgents: () => void
}) {
return (
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
Set up OpenClaw agents to turn your new tab into an agent command
center.
</p>
<Button onClick={onOpenAgents} className="gap-2">
Open Agent Setup
<ArrowRight className="size-4" />
</Button>
</CardContent>
</Card>
)
}
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
return (
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
OpenClaw is running, but you do not have any agents yet.
</p>
<Button variant="outline" onClick={onOpenAgents}>
Create your first agent
</Button>
</CardContent>
</Card>
)
}
function OpenClawUnavailableState({
onOpenAgents,
}: {
onOpenAgents: () => void
}) {
return (
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
OpenClaw is unavailable right now. Open the Agents page to restart the
gateway or review setup.
</p>
<Button onClick={onOpenAgents} className="gap-2">
Open Agent Setup
<ArrowRight className="size-4" />
</Button>
</CardContent>
</Card>
)
}
export const AgentCommandHome: FC = () => {
const navigate = useNavigate()
const activeHint = useActiveHint()
const { status, agents } = useAgentCommandData()
const [mounted, setMounted] = useState(false)
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const cardData = useAgentCardData(agents, status?.status)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (agents.length === 0) {
if (selectedAgentId) {
setSelectedAgentId(null)
}
return
}
if (
!selectedAgentId ||
!agents.some((agent) => agent.agentId === selectedAgentId)
) {
setSelectedAgentId(agents[0].agentId)
}
}, [agents, selectedAgentId])
const handleSend = (text: string) => {
if (!selectedAgentId) return
navigate(`/home/agents/${selectedAgentId}?q=${encodeURIComponent(text)}`)
}
const handleSelectAgent = (agent: AgentEntry) => {
setSelectedAgentId(agent.agentId)
}
const openClawStatus = status?.status
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
const shouldShowUnavailableState =
openClawStatus != null &&
openClawStatus !== 'running' &&
openClawStatus !== 'uninitialized' &&
cardData.length === 0
return (
<div className="pt-[max(25vh,16px)]">
<div className="relative w-full space-y-8 md:w-3xl">
<NewTabBranding />
<ConversationInput
variant="home"
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
onSend={handleSend}
onCreateAgent={() => navigate('/agents')}
streaming={false}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={
status?.status === 'running'
? undefined
: 'OpenClaw is not running...'
}
/>
{mounted ? <NewTabTip /> : null}
{isSetup ? (
shouldShowUnavailableState ? (
<OpenClawUnavailableState
onOpenAgents={() => navigate('/agents')}
/>
) : cardData.length > 0 ? (
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Agents</h2>
<p className="text-muted-foreground text-sm">
Pick up where your agents left off.
</p>
</div>
</div>
<AgentCardDock
agents={cardData}
activeAgentId={selectedAgentId ?? undefined}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
onCreateAgent={() => navigate('/agents')}
/>
</section>
) : (
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
)
) : (
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
)}
{mounted ? <TopSites /> : null}
{mounted ? <ScheduleResults /> : null}
</div>
{activeHint === 'signin' ? <SignInHint /> : null}
{activeHint === 'import' ? <ImportDataHint /> : null}
</div>
)
}

View File

@@ -1,132 +0,0 @@
import { Bot, Check, ChevronDown, Plus } from 'lucide-react'
import type { FC } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
type AgentEntry,
getModelDisplayName,
} from '@/entrypoints/app/agents/useOpenClaw'
import { cn } from '@/lib/utils'
interface AgentSelectorProps {
agents: AgentEntry[]
selectedAgentId: string | null
onSelectAgent: (agent: AgentEntry) => void
onCreateAgent?: () => void
status?: string
}
function getStatusDot(status?: string) {
if (status === 'running') return 'bg-emerald-500'
if (status === 'starting') return 'bg-amber-500 animate-pulse'
if (status === 'error') return 'bg-destructive'
return 'bg-muted-foreground/50'
}
export const AgentSelector: FC<AgentSelectorProps> = ({
agents,
selectedAgentId,
onSelectAgent,
onCreateAgent,
status,
}) => {
const [open, setOpen] = useState(false)
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-72 p-0">
<Command>
<CommandInput placeholder="Search agents..." className="h-9" />
<CommandList>
<CommandEmpty>No agents found</CommandEmpty>
<CommandGroup>
{agents.map((agent) => {
const isSelected = selectedAgentId === agent.agentId
const modelLabel = getModelDisplayName(agent.model)
return (
<CommandItem
key={agent.agentId}
value={`${agent.agentId} ${agent.name}`}
onSelect={() => {
onSelectAgent(agent)
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2',
isSelected && 'bg-[var(--accent-orange)]/10',
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]">
<Bot className="size-4" />
</div>
<div className="min-w-0 flex-1">
<span className="block truncate font-medium text-sm">
{agent.name}
</span>
{modelLabel ? (
<span className="block truncate text-muted-foreground text-xs">
{modelLabel}
</span>
) : null}
</div>
{isSelected ? (
<Check className="size-4 shrink-0 text-[var(--accent-orange)]" />
) : null}
</CommandItem>
)
})}
</CommandGroup>
{onCreateAgent ? (
<div className="border-border border-t p-1">
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
onCreateAgent()
setOpen(false)
}}
>
<Plus className="size-4" />
<span>Create agent</span>
</button>
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,371 +0,0 @@
import {
ArrowRight,
Bot,
ChevronDown,
Folder,
Layers,
Loader2,
Mic,
Square,
} from 'lucide-react'
import { type FC, type ReactNode, useEffect, useState } from 'react'
import { AppSelector } from '@/components/elements/AppSelector'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { Button } from '@/components/ui/button'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { cn } from '@/lib/utils'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { AgentSelector } from './AgentSelector'
interface ConversationInputProps {
agents: AgentEntry[]
selectedAgentId: string | null
onSelectAgent: (agent: AgentEntry) => void
onSend: (text: string) => void
onCreateAgent?: () => void
streaming: boolean
disabled?: boolean
status?: string
placeholder?: string
variant?: 'home' | 'conversation'
}
function InputActionButton({
disabled,
onClick,
streaming,
}: {
disabled: boolean
onClick: () => void
streaming: boolean
}) {
return (
<Button
onClick={onClick}
size="icon"
disabled={disabled}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
{streaming ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowRight className="h-5 w-5" />
)}
</Button>
)
}
function VoiceButton({
isRecording,
isTranscribing,
onStart,
onStop,
}: {
isRecording: boolean
isTranscribing: boolean
onStart: () => void
onStop: () => void
}) {
if (isRecording) {
return (
<Button
type="button"
size="icon"
onClick={onStop}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-red-600 text-white hover:bg-red-700"
>
<Square className="h-4 w-4" />
</Button>
)
}
if (isTranscribing) {
return (
<Button
type="button"
variant="ghost"
size="icon"
disabled
className="h-10 w-10 flex-shrink-0 rounded-xl"
>
<Loader2 className="h-5 w-5 animate-spin" />
</Button>
)
}
return (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onStart}
className="h-10 w-10 flex-shrink-0 rounded-xl text-muted-foreground transition-colors hover:text-foreground"
title="Voice input"
>
<Mic className="h-5 w-5" />
</Button>
)
}
function ContextControls({
agents,
onCreateAgent,
onSelectAgent,
selectedAgentId,
selectedTabs,
onToggleTab,
showAgentSelector,
status,
}: {
agents: AgentEntry[]
onCreateAgent?: () => void
onSelectAgent: (agent: AgentEntry) => void
selectedAgentId: string | null
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
showAgentSelector: boolean
status?: string
}) {
const { supports } = useCapabilities()
const { selectedFolder } = useWorkspace()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
const connectedManagedServers = mcpServers.filter((server) => {
if (server.type !== 'managed' || !server.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
(integration) => integration.name === server.managedServerName,
)?.is_authenticated
})
return (
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{showAgentSelector ? (
<AgentSelector
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={onSelectAgent}
onCreateAgent={onCreateAgent}
status={status}
/>
) : null}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
<WorkspaceSelector>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Folder className="h-4 w-4" />
<span>{selectedFolder?.name || 'Add workspace'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
) : null}
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
>
<Button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
selectedTabs.length > 0
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Layers className="h-4 w-4" />
<span>Tabs</span>
</Button>
</TabPickerPopover>
</div>
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
<div className="ml-auto flex items-center gap-1.5">
<AppSelector side="bottom">
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<div className="flex items-center -space-x-1.5">
{connectedManagedServers.slice(0, 4).map((server) => (
<div
key={server.id}
className="rounded-full ring-2 ring-card"
>
<McpServerIcon
serverName={server.managedServerName ?? ''}
size={16}
/>
</div>
))}
</div>
{connectedManagedServers.length > 4 ? (
<span className="text-xs">
+{connectedManagedServers.length - 4}
</span>
) : null}
<span>Apps</span>
<ChevronDown className="h-3 w-3" />
</Button>
</AppSelector>
</div>
) : null}
</div>
)
}
function HomeShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
{children}
</div>
)
}
function ConversationShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
{children}
</div>
)
}
export const ConversationInput: FC<ConversationInputProps> = ({
agents,
selectedAgentId,
onSelectAgent,
onSend,
onCreateAgent,
streaming,
disabled,
status,
placeholder,
variant = 'conversation',
}) => {
const [input, setInput] = useState('')
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const voice = useVoiceInput()
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setInput(voice.transcript)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing, voice])
const toggleTab = (tab: chrome.tabs.Tab) => {
setSelectedTabs((prev) => {
const isSelected = prev.some((selected) => selected.id === tab.id)
if (isSelected) {
return prev.filter((selected) => selected.id !== tab.id)
}
return [...prev, tab]
})
}
const handleSend = () => {
const text = input.trim()
if (!text || streaming || disabled) return
onSend(text)
setInput('')
}
const shell = variant === 'home' ? HomeShell : ConversationShell
const Shell = shell
return (
<Shell>
<div className="flex items-center gap-3 px-5 py-4">
<BotInputIcon variant={variant} />
<input
type="text"
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleSend()
}
}}
placeholder={
voice.isTranscribing
? 'Transcribing...'
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
}
disabled={disabled || voice.isTranscribing}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
/>
<VoiceButton
isRecording={voice.isRecording}
isTranscribing={voice.isTranscribing}
onStart={() => {
void voice.startRecording()
}}
onStop={() => {
void voice.stopRecording()
}}
/>
<InputActionButton
disabled={
!input.trim() ||
streaming ||
!!disabled ||
voice.isRecording ||
voice.isTranscribing
}
onClick={handleSend}
streaming={streaming}
/>
</div>
{voice.error ? (
<div className="px-5 pb-2 text-destructive text-xs">{voice.error}</div>
) : null}
<ContextControls
agents={agents}
onCreateAgent={onCreateAgent}
onSelectAgent={onSelectAgent}
selectedAgentId={selectedAgentId}
selectedTabs={selectedTabs}
onToggleTab={toggleTab}
showAgentSelector={variant === 'home'}
status={status}
/>
</Shell>
)
}
function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
return (
<div
className={cn(
'flex items-center justify-center text-[var(--accent-orange)]',
variant === 'home'
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
)}
>
<Bot className="h-4 w-4" />
</div>
)
}

View File

@@ -1,105 +0,0 @@
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'
import type { FC } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
interface ConversationMessageProps {
turn: AgentConversationTurn
streaming: boolean
}
export const ConversationMessage: FC<ConversationMessageProps> = ({
turn,
streaming,
}) => (
<div className="space-y-3">
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">{tool.name}</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return <MessageResponse key={key}>{part.text}</MessageResponse>
default:
return null
}
})}
</MessageContent>
</Message>
)}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="size-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
)

View File

@@ -1,39 +0,0 @@
import type { FC } from 'react'
import { Outlet, useOutletContext } from 'react-router'
import {
type AgentEntry,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
interface AgentCommandContextValue {
agents: AgentEntry[]
agentsLoading: boolean
status: OpenClawStatus | null
statusLoading: boolean
}
export const AgentCommandLayout: FC = () => {
const { status, loading: statusLoading } = useOpenClawStatus(5000)
const { agents, loading: agentsLoading } = useOpenClawAgents(
status?.status === 'running' && status.controlPlaneStatus === 'connected',
)
return (
<Outlet
context={
{
agents,
agentsLoading,
status,
statusLoading,
} satisfies AgentCommandContextValue
}
/>
)
}
export function useAgentCommandData(): AgentCommandContextValue {
return useOutletContext<AgentCommandContextValue>()
}

View File

@@ -1,69 +0,0 @@
import { useEffect, useState } from 'react'
import {
type AgentEntry,
getModelDisplayName,
type OpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
import { getLatestConversation } from '@/lib/agent-conversations/storage'
import type { AgentCardData } from '@/lib/agent-conversations/types'
function getAgentStatusTone(
status: OpenClawStatus['status'] | undefined,
): AgentCardData['status'] {
if (status === 'error') return 'error'
if (status === 'starting') return 'working'
return 'idle'
}
async function getAgentCardData(
agent: AgentEntry,
status: OpenClawStatus['status'] | undefined,
): Promise<AgentCardData> {
const conversation = await getLatestConversation(agent.agentId)
const lastTurn = conversation?.turns[conversation.turns.length - 1]
const lastTextPart = lastTurn?.parts.findLast((part) => part.kind === 'text')
return {
agentId: agent.agentId,
name: agent.name,
model: getModelDisplayName(agent.model),
status: getAgentStatusTone(status),
lastMessage:
lastTextPart?.kind === 'text'
? lastTextPart.text.slice(0, 120)
: undefined,
lastMessageTimestamp: lastTurn?.timestamp,
}
}
export function useAgentCardData(
agents: AgentEntry[],
status: OpenClawStatus['status'] | undefined,
) {
const [cardData, setCardData] = useState<AgentCardData[]>([])
useEffect(() => {
let active = true
const loadCardData = async () => {
const nextCardData = await Promise.all(
agents.map((agent) => getAgentCardData(agent, status)),
)
if (active) {
setCardData(nextCardData)
}
}
if (agents.length > 0) {
void loadCardData()
} else {
setCardData([])
}
return () => {
active = false
}
}, [agents, status])
return cardData
}

View File

@@ -1,256 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import {
chatWithAgent,
type OpenClawStreamEvent,
} from '@/entrypoints/app/agents/useOpenClaw'
import {
getLatestConversation,
saveConversation,
} from '@/lib/agent-conversations/storage'
import type {
AgentConversation,
AgentConversationTurn,
AssistantPart,
} from '@/lib/agent-conversations/types'
import { consumeSSEStream } from '@/lib/sse'
export function useAgentConversation(agentId: string, agentName: string) {
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
const [streaming, setStreaming] = useState(false)
const [loading, setLoading] = useState(true)
const sessionKeyRef = useRef('')
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const streamAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
let active = true
getLatestConversation(agentId)
.then((conv) => {
if (!active) return
if (conv) {
setTurns(conv.turns)
sessionKeyRef.current = conv.sessionKey
} else {
sessionKeyRef.current = crypto.randomUUID()
}
setLoading(false)
})
.catch(() => {
if (active) {
sessionKeyRef.current = crypto.randomUUID()
setLoading(false)
}
})
return () => {
active = false
}
}, [agentId])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
const conv: AgentConversation = {
agentId,
agentName,
sessionKey: sessionKeyRef.current,
turns: updatedTurns,
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
updatedAt: Date.now(),
}
saveConversation(conv).catch(() => {})
}
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running' as const,
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const toolStatus: 'completed' | 'error' =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
const updated = [...prev.slice(0, -1), { ...last, done: true }]
persistTurns(updated)
return updated
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const send = async (text: string) => {
if (!text.trim() || streaming) return
const turn: AgentConversationTurn = {
id: crypto.randomUUID(),
userText: text.trim(),
parts: [],
done: false,
timestamp: Date.now(),
}
setTurns((prev) => [...prev, turn])
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text.trim(),
sessionKeyRef.current,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
}
const resetConversation = () => {
streamAbortRef.current?.abort()
streamAbortRef.current = null
setTurns([])
setStreaming(false)
sessionKeyRef.current = crypto.randomUUID()
}
return {
turns,
streaming,
loading,
sessionKey: sessionKeyRef.current,
send,
resetConversation,
}
}

View File

@@ -1,393 +0,0 @@
import {
ArrowLeft,
Bot,
CheckCircle2,
Loader2,
Send,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
type AssistantPart =
| { kind: 'thinking'; text: string; done: boolean }
| { kind: 'tool-batch'; tools: ToolEntry[] }
| { kind: 'text'; text: string }
interface ChatTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
}
interface AgentChatProps {
agentId: string
agentName: string
onBack: () => void
}
export const AgentChat: FC<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const streamAbortRef = useRef<AbortController | null>(null)
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const scrollToBottom = () => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
useEffect(() => {
scrollToBottom()
}, [turns])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool: ToolEntry = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running',
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const status =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId
? {
...t,
status: status as ToolEntry['status'],
durationMs,
}
: t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const turn: ChatTurn = {
id: crypto.randomUUID(),
userText: text,
parts: [],
done: false,
}
setTurns((prev) => [...prev, turn])
setInput('')
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text,
sessionKeyRef.current,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
}
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<h2 className="font-semibold text-lg">{agentName}</h2>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">
{tool.name}
</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return (
<MessageResponse key={key}>
{part.text}
</MessageResponse>
)
default:
return null
}
})}
</MessageContent>
</Message>
)}
{/* Streaming indicator when waiting for first part */}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
))}
</div>
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Send a message..."
className="min-h-[44px] resize-none"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || streaming}
size="icon"
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,280 +0,0 @@
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_TERMINAL_SHELL,
} from '@browseros/shared/constants/openclaw'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Terminal } from '@xterm/xterm'
import { ArrowLeft } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import '@xterm/xterm/css/xterm.css'
import { Button } from '@/components/ui/button'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface AgentTerminalProps {
onBack: () => void
}
type TerminalServerMessage =
| { type: 'output'; data: string }
| { type: 'exit'; exitCode: number }
| { type: 'error'; message: string }
const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
const TERMINAL_FONT_FAMILY =
'"Geist Mono", Menlo, Monaco, "Courier New", monospace'
function resolveCssColor(variableName: string): string {
const probe = document.createElement('div')
probe.style.position = 'fixed'
probe.style.visibility = 'hidden'
probe.style.pointerEvents = 'none'
probe.style.color = `var(${variableName})`
document.body.append(probe)
const color = window.getComputedStyle(probe).color
probe.remove()
return color
}
function withAlpha(color: string, alpha: number): string {
const channels = color.match(/[\d.]+/g)
if (!channels || channels.length < 3) return color
const [red, green, blue] = channels
return `rgb(${red} ${green} ${blue} / ${alpha})`
}
function createTerminalTheme() {
const isDark = document.documentElement.classList.contains('dark')
const background = resolveCssColor('--background')
const foreground = resolveCssColor('--foreground')
const muted = resolveCssColor('--muted-foreground')
const accent = resolveCssColor('--accent-orange')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
selectionForeground: foreground,
black: isDark ? '#16131a' : '#1f1b22',
red: isDark ? '#ef8c7c' : '#c25544',
green: isDark ? '#9ac67c' : '#5c8754',
yellow: isDark ? '#e5c07b' : '#b7791f',
blue: isDark ? '#8ba9ff' : '#4667d8',
magenta: isDark ? '#d2a8ff' : '#955ec7',
cyan: isDark ? '#7fd4d1' : '#0f766e',
white: isDark ? '#e8e0d9' : '#f7f1eb',
brightBlack: muted,
brightRed: isDark ? '#ffb0a4' : '#dc7b6d',
brightGreen: isDark ? '#b6d99e' : '#7bab74',
brightYellow: isDark ? '#f2d59b' : '#d49a44',
brightBlue: isDark ? '#b3c4ff' : '#6f8cf0',
brightMagenta: isDark ? '#e2c6ff' : '#b789dd',
brightCyan: isDark ? '#a6ece7' : '#3aa5a0',
brightWhite: isDark ? '#fff8f1' : '#ffffff',
}
}
function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
if (typeof data !== 'string') return null
let parsed: unknown
try {
parsed = JSON.parse(data) as unknown
} catch {
return null
}
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === 'output' &&
'data' in parsed &&
typeof parsed.data === 'string'
) {
return { type: 'output', data: parsed.data }
}
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === 'error' &&
'message' in parsed &&
typeof parsed.message === 'string'
) {
return { type: 'error', message: parsed.message }
}
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === 'exit' &&
'exitCode' in parsed &&
typeof parsed.exitCode === 'number'
) {
return { type: 'exit', exitCode: parsed.exitCode }
}
return null
}
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const terminal = new Terminal({
fontSize: 14,
fontFamily: TERMINAL_FONT_FAMILY,
cursorBlink: true,
cursorStyle: 'block',
lineHeight: 1.25,
scrollback: 8000,
theme: createTerminalTheme(),
})
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.open(containerRef.current)
let ws: WebSocket | null = null
let sawExit = false
const applyTheme = (): void => {
terminal.options.theme = createTerminalTheme()
}
const sendMessage = (
message:
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number },
): void => {
if (ws?.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify(message))
}
const sendResize = (cols = terminal.cols, rows = terminal.rows): void => {
sendMessage({ type: 'resize', cols, rows })
}
const connect = async () => {
const baseUrl = await getAgentServerUrl()
const wsUrl = new URL('/terminal/ws', baseUrl)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(wsUrl)
ws.onopen = () => {
fitAddon.fit()
terminal.focus()
sendResize()
}
ws.onmessage = (event) => {
const message = parseTerminalMessage(event.data)
if (!message) return
if (message.type === 'output') {
terminal.write(message.data)
} else if (message.type === 'error') {
terminal.write(`\r\n\x1b[31m${message.message}\x1b[0m\r\n`)
} else {
sawExit = true
terminal.write(
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
)
}
}
ws.onclose = () => {
if (sawExit) return
terminal.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n')
}
ws.onerror = () => {
terminal.write('\r\n\x1b[31m[connection error]\x1b[0m\r\n')
}
const inputDisposable = terminal.onData((data) => {
sendMessage({ type: 'input', data })
})
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
sendResize(cols, rows)
})
return () => {
inputDisposable.dispose()
resizeDisposable.dispose()
}
}
let disposeSocketBindings: (() => void) | undefined
void connect().then((disposeBindings) => {
disposeSocketBindings = disposeBindings
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
sendResize()
})
resizeObserver.observe(containerRef.current)
const themeObserver = new MutationObserver(() => {
applyTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
disposeSocketBindings?.()
ws?.close()
terminal.dispose()
}
}, [])
return (
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">
Container Terminal
</div>
<div className="truncate text-muted-foreground text-sm">
OpenClaw shell in {TERMINAL_HOME_DIR}
</div>
</div>
</div>
</div>
<div className="min-h-0 flex-1 p-4 sm:p-6">
<div className="agent-terminal-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background">
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-2.5">
<div className="truncate font-mono text-muted-foreground text-xs">
{TERMINAL_HOME_DIR}
</div>
<div className="font-mono text-[11px] text-muted-foreground">
{OPENCLAW_TERMINAL_SHELL.split('/').pop()}
</div>
</div>
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
<div ref={containerRef} className="h-full w-full" />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,330 +0,0 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export interface AgentEntry {
agentId: string
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error'
podmanAvailable: boolean
machineReady: boolean
port: number | null
agentCount: number
error: string | null
controlPlaneStatus:
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'recovering'
| 'failed'
lastGatewayError: string | null
lastRecoveryReason:
| 'transient_disconnect'
| 'signature_expired'
| 'pairing_required'
| 'token_mismatch'
| 'container_not_ready'
| 'unknown'
| null
}
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}
export interface OpenClawSetupInput {
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}
export function getModelDisplayName(model: unknown): string | undefined {
if (typeof model === 'string') return model.split('/').pop()
return undefined
}
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
} as const
async function clawFetch<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${baseUrl}/claw${path}`, init)
if (!res.ok) {
let message = `Request failed with status ${res.status}`
try {
const body = (await res.json()) as { error?: string }
if (body.error) {
message = body.error
}
} catch {}
throw new Error(message)
}
return res.json() as Promise<T>
}
async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
return clawFetch<OpenClawStatus>(baseUrl, '/status')
}
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.status] }),
queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.agents] }),
])
}
export function useOpenClawStatus(pollMs = 5000) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<OpenClawStatus, Error>({
queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl],
queryFn: () => fetchOpenClawStatus(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
refetchInterval: pollMs,
})
return {
status: query.data ?? null,
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawAgents(enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.agents, baseUrl],
queryFn: () => fetchOpenClawAgents(baseUrl as string),
enabled: !!baseUrl && !urlLoading && enabled,
})
return {
agents: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
const ensureBaseUrl = () => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
return baseUrl
}
const onSuccess = () => invalidateOpenClawQueries(queryClient)
const setupMutation = useMutation({
mutationFn: async (input: OpenClawSetupInput) =>
clawFetch<{ status: string; agents: AgentEntry[] }>(
ensureBaseUrl(),
'/setup',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess,
})
const createMutation = useMutation({
mutationFn: async (input: OpenClawAgentMutationInput) =>
clawFetch<{ agent: AgentEntry }>(ensureBaseUrl(), '/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
onSuccess,
})
const deleteMutation = useMutation({
mutationFn: async (id: string) =>
clawFetch<{ success: boolean }>(ensureBaseUrl(), `/agents/${id}`, {
method: 'DELETE',
}),
onSuccess,
})
const startMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/start', {
method: 'POST',
}),
onSuccess,
})
const stopMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/stop', {
method: 'POST',
}),
onSuccess,
})
const restartMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/restart', {
method: 'POST',
}),
onSuccess,
})
const reconnectMutation = useMutation({
mutationFn: async () =>
clawFetch<{ status: string }>(ensureBaseUrl(), '/reconnect', {
method: 'POST',
}),
onSuccess,
})
return {
setupOpenClaw: setupMutation.mutateAsync,
createAgent: createMutation.mutateAsync,
deleteAgent: deleteMutation.mutateAsync,
startOpenClaw: startMutation.mutateAsync,
stopOpenClaw: stopMutation.mutateAsync,
restartOpenClaw: restartMutation.mutateAsync,
reconnectOpenClaw: reconnectMutation.mutateAsync,
actionInProgress:
setupMutation.isPending ||
createMutation.isPending ||
deleteMutation.isPending ||
startMutation.isPending ||
stopMutation.isPending ||
restartMutation.isPending ||
reconnectMutation.isPending,
settingUp: setupMutation.isPending,
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
}
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export async function chatWithAgent(
agentId: string,
message: string,
sessionKey?: string,
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionKey }),
signal,
})
}

View File

@@ -1,120 +0,0 @@
import {
Bot,
Camera,
Code,
Database,
Eye,
Hand,
MousePointerClick,
Navigation,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import {
TOOL_CATEGORIES,
type ToolApprovalConfig,
} from '@/lib/tool-approvals/types'
const CATEGORY_ICONS: Record<string, typeof Hand> = {
input: MousePointerClick,
navigation: Navigation,
observation: Eye,
screenshots: Camera,
scripts: Code,
'data-modification': Database,
assistant: Bot,
}
export const ToolApprovalsPage: FC = () => {
const [config, setConfig] = useState<ToolApprovalConfig>({ categories: {} })
useEffect(() => {
const applyConfig = (value: ToolApprovalConfig) =>
setConfig(normalizeToolApprovalConfig(value))
toolApprovalConfigStorage.getValue().then(applyConfig)
const unwatch = toolApprovalConfigStorage.watch(applyConfig)
return () => unwatch()
}, [])
const allEnabled =
TOOL_CATEGORIES.length > 0 &&
TOOL_CATEGORIES.every((category) => config.categories[category.id] === true)
const toggleCategory = (categoryId: string, enabled: boolean) => {
const next = {
...config,
categories: { ...config.categories, [categoryId]: enabled },
}
setConfig(next)
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
}
const toggleAll = (enabled: boolean) => {
const categories: Record<string, boolean> = {}
for (const cat of TOOL_CATEGORIES) {
categories[cat.id] = enabled
}
const next = { ...config, categories }
setConfig(next)
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
}
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold text-xl tracking-tight">Tool Approvals</h2>
<p className="text-muted-foreground text-sm">
Require human approval before the agent executes certain actions.
Changes apply immediately.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="space-y-0.5">
<div className="font-medium text-sm">Require approval for all</div>
<div className="text-muted-foreground text-xs">
Toggle all categories at once
</div>
</div>
<Switch checked={allEnabled} onCheckedChange={toggleAll} />
</div>
<div className="space-y-3">
{TOOL_CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.id] ?? Hand
const enabled = config.categories[category.id] ?? false
return (
<div
key={category.id}
className="flex items-start gap-4 rounded-lg border bg-card p-4 transition-colors"
>
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{category.name}</span>
</div>
<p className="text-muted-foreground text-xs">
{category.description}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
toggleCategory(category.id, checked)
}
/>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -2,20 +2,21 @@ import type { FC } from 'react'
import { Outlet, useLocation } from 'react-router'
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { NewTabFocusGrid } from './NewTabFocusGrid'
import { shouldHideFocusGrid, shouldUseChatSession } from './route-utils'
const HIDE_FOCUS_GRID_PATHS = new Set([
'/home/soul',
'/home/memory',
'/home/skills',
'/home/chat',
])
export const NewTabLayout: FC = () => {
const location = useLocation()
const hideGrid = shouldHideFocusGrid(location.pathname)
const useChatSession = shouldUseChatSession(location.pathname)
const content = (
<>
{!hideGrid && <NewTabFocusGrid />}
return (
<ChatSessionProvider origin="newtab">
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
<Outlet />
</>
</ChatSessionProvider>
)
if (!useChatSession) return content
return <ChatSessionProvider origin="newtab">{content}</ChatSessionProvider>
}

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from 'bun:test'
import {
isAgentCommandPath,
isAgentConversationPath,
shouldHideFocusGrid,
shouldUseChatSession,
} from './route-utils'
describe('route-utils', () => {
it('treats command center routes as non-chat-session paths', () => {
expect(isAgentCommandPath('/home')).toBe(true)
expect(isAgentCommandPath('/home/agents/main')).toBe(true)
expect(isAgentConversationPath('/home')).toBe(false)
expect(isAgentConversationPath('/home/agents/main')).toBe(true)
expect(shouldUseChatSession('/home')).toBe(false)
expect(shouldUseChatSession('/home/agents/main')).toBe(false)
expect(shouldUseChatSession('/home/chat')).toBe(true)
})
it('keeps the focus grid on home while hiding it on dedicated full-screen routes', () => {
expect(shouldHideFocusGrid('/home')).toBe(false)
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
expect(shouldHideFocusGrid('/home/personalize')).toBe(false)
})
})

View File

@@ -1,24 +0,0 @@
const HIDE_FOCUS_GRID_PATHS = new Set([
'/home/soul',
'/home/memory',
'/home/skills',
'/home/chat',
])
export function isAgentCommandPath(pathname: string): boolean {
return pathname === '/home' || isAgentConversationPath(pathname)
}
export function isAgentConversationPath(pathname: string): boolean {
return pathname.startsWith('/home/agents/')
}
export function shouldHideFocusGrid(pathname: string): boolean {
return (
HIDE_FOCUS_GRID_PATHS.has(pathname) || isAgentConversationPath(pathname)
)
}
export function shouldUseChatSession(pathname: string): boolean {
return pathname === '/home/chat'
}

View File

@@ -43,7 +43,6 @@ export const Chat = () => {
disliked,
onClickDislike,
isRestoringConversation,
addToolApprovalResponse,
} = useChatSessionContext()
const {
@@ -223,12 +222,6 @@ export const Chat = () => {
showDontShowAgain={showDontShowAgain}
onTakeSurvey={onTakeSurvey}
onDismissJtbdPopup={onDismissJtbdPopup}
onToolApprove={(id) =>
addToolApprovalResponse({ id, approved: true })
}
onToolDeny={(id) =>
addToolApprovalResponse({ id, approved: false })
}
/>
)}
{agentUrlError && (

View File

@@ -37,8 +37,6 @@ interface ChatMessagesProps {
showDontShowAgain: boolean
onTakeSurvey: (opts?: { dontShowAgain?: boolean }) => void
onDismissJtbdPopup: (dontShowAgain: boolean) => void
onToolApprove?: (approvalId: string) => void
onToolDeny?: (approvalId: string) => void
}
export const ChatMessages: FC<ChatMessagesProps> = ({
@@ -53,8 +51,6 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
showDontShowAgain,
onTakeSurvey,
onDismissJtbdPopup,
onToolApprove,
onToolDeny,
}) => {
const isStreaming = status === 'streaming' || status === 'submitted'
@@ -118,8 +114,6 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
isLastBatch={segment.key === lastToolBatchKey}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
onApprove={onToolApprove}
onDeny={onToolDeny}
/>
)
case 'nudge':

View File

@@ -2,20 +2,16 @@ import {
BotIcon,
CheckCircle2,
CircleDashed,
Clock,
Loader2,
ShieldCheck,
ShieldX,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import type { FC } from 'react'
import {
Task,
TaskContent,
TaskItem,
TaskTrigger,
} from '@/components/ai-elements/task'
import { Button } from '@/components/ui/button'
import type {
ToolInvocationInfo,
ToolInvocationState,
@@ -26,8 +22,6 @@ interface ToolBatchProps {
isLastBatch: boolean
isLastMessage: boolean
isStreaming: boolean
onApprove?: (approvalId: string) => void
onDeny?: (approvalId: string) => void
}
export const ToolBatch: FC<ToolBatchProps> = ({
@@ -35,20 +29,12 @@ export const ToolBatch: FC<ToolBatchProps> = ({
isLastBatch,
isLastMessage,
isStreaming,
onApprove,
onDeny,
}) => {
const hasPendingApproval = tools.some((t) => t.state === 'approval-requested')
const shouldBeOpen =
(isLastMessage && isLastBatch && isStreaming) || hasPendingApproval
const shouldBeOpen = isLastMessage && isLastBatch && isStreaming
const [isOpen, setIsOpen] = useState(shouldBeOpen)
const [hasUserInteracted, setHasUserInteracted] = useState(false)
useEffect(() => {
if (hasPendingApproval) {
setIsOpen(true)
return
}
if (isLastMessage && !hasUserInteracted) {
if (isLastBatch) {
setIsOpen(isStreaming)
@@ -56,18 +42,9 @@ export const ToolBatch: FC<ToolBatchProps> = ({
setIsOpen(false)
}
}
}, [
isStreaming,
isLastMessage,
isLastBatch,
hasUserInteracted,
hasPendingApproval,
])
}, [isStreaming, isLastMessage, isLastBatch, hasUserInteracted])
const completedCount = tools.filter((t) => isToolCompleted(t.state)).length
const triggerTitle = hasPendingApproval
? 'Waiting for approval...'
: `${completedCount}/${tools.length} actions completed`
const onManualToggle = (newState: boolean) => {
setHasUserInteracted(true)
@@ -76,23 +53,16 @@ export const ToolBatch: FC<ToolBatchProps> = ({
return (
<Task open={isOpen} onOpenChange={onManualToggle}>
<TaskTrigger title={triggerTitle} TriggerIcon={BotIcon} />
<TaskTrigger
title={`${completedCount}/${tools.length} actions completed`}
TriggerIcon={BotIcon}
/>
<TaskContent>
{tools.map((tool) => (
<div key={tool.toolCallId}>
<TaskItem className="flex items-center gap-2">
<ToolStatusIcon state={tool.state} />
<span className="flex-1">{formatToolName(tool.toolName)}</span>
</TaskItem>
{tool.state === 'approval-requested' &&
tool.approval?.id != null && (
<ApprovalButtons
approvalId={tool.approval.id}
onApprove={onApprove}
onDeny={onDeny}
/>
)}
</div>
<TaskItem key={tool.toolCallId} className="flex items-center gap-2">
<ToolStatusIcon state={tool.state} />
<span>{formatToolName(tool.toolName)}</span>
</TaskItem>
))}
</TaskContent>
</Task>
@@ -114,47 +84,10 @@ const isToolInProgress = (state: ToolInvocationState) =>
const isToolError = (state: ToolInvocationState) => state === 'output-error'
const isToolDenied = (state: ToolInvocationState) => state === 'output-denied'
const isToolApprovalPending = (state: ToolInvocationState) =>
state === 'approval-requested'
const ApprovalButtons: FC<{
approvalId: string
onApprove?: (id: string) => void
onDeny?: (id: string) => void
}> = ({ approvalId, onApprove, onDeny }) => (
<div className="mt-1 mb-2 ml-6 flex items-center gap-2">
<Button
size="sm"
className="h-7 gap-1 px-2.5 text-xs"
onClick={() => onApprove?.(approvalId)}
>
<ShieldCheck className="size-3" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2.5 text-xs"
onClick={() => onDeny?.(approvalId)}
>
<ShieldX className="size-3" />
Deny
</Button>
</div>
)
const ToolStatusIcon: FC<{ state: ToolInvocationState }> = ({ state }) => {
if (isToolCompleted(state)) {
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
}
if (isToolApprovalPending(state)) {
return <Clock className="h-3.5 w-3.5 text-yellow-500" />
}
if (isToolDenied(state)) {
return <ShieldX className="h-3.5 w-3.5 text-red-400" />
}
if (isToolInProgress(state)) {
return (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--accent-orange)]" />

View File

@@ -8,9 +8,6 @@ export type ToolInvocationState =
| 'input-available'
| 'output-available'
| 'output-error'
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
export interface ToolInvocationInfo {
state: ToolInvocationState
@@ -18,7 +15,6 @@ export interface ToolInvocationInfo {
toolName: string
input: Record<string, unknown>
output: unknown[]
approval?: { id: string; approved?: boolean; reason?: string }
}
export type NudgeType = 'schedule_suggestion' | 'app_connection'
@@ -110,7 +106,6 @@ export const getMessageSegments = (
state: ToolInvocationState
input: Record<string, unknown>
output: unknown
approval?: { id: string; approved?: boolean; reason?: string }
}
const toolName = toolPart.type?.replace('tool-', '')
@@ -132,7 +127,6 @@ export const getMessageSegments = (
toolName,
input: toolPart?.input ?? {},
output: (toolPart?.output as unknown[]) ?? [],
approval: toolPart?.approval,
})
}
}

View File

@@ -1,11 +1,10 @@
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { compact } from 'es-toolkit/array'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { aclRulesStorage } from '@/lib/acl/storage'
import { Capabilities, Feature } from '@/lib/browseros/capabilities'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type { ChatAction } from '@/lib/chat-actions/types'
@@ -27,59 +26,17 @@ import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import {
type ApprovalResponseData,
buildChatRequestBody,
type ChatRequestBrowserContext,
} from '@/lib/messaging/server/buildChatRequestBody'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import {
type ApprovalResponse,
approvalResponsesStorage,
extractPendingApprovals,
pendingToolApprovalsStorage,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from '@/lib/tool-approvals/approval-sync-storage'
import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
import { useChatRefs } from './useChatRefs'
import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
import { useNotifyActiveTab } from './useNotifyActiveTab'
import { useRemoteConversationSave } from './useRemoteConversationSave'
const extractApprovalResponses = (
messages: UIMessage[],
): ApprovalResponseData[] | null => {
const lastMsg = messages[messages.length - 1]
if (lastMsg?.role !== 'assistant') return null
const approvals: ApprovalResponseData[] = []
for (const part of lastMsg.parts) {
const p = part as {
state?: string
approval?: { id: string; approved?: boolean; reason?: string }
}
if (p.state === 'approval-responded' && p.approval?.approved != null) {
approvals.push({
approvalId: p.approval.id,
approved: p.approval.approved,
reason: p.approval.reason,
})
}
}
return approvals.length > 0 ? approvals : null
}
const getLastMessageText = (messages: UIMessage[]) => {
const lastMessage = messages[messages.length - 1]
if (!lastMessage) return ''
@@ -89,15 +46,6 @@ const getLastMessageText = (messages: UIMessage[]) => {
.join('')
}
const getLastUserMessageText = (messages: UIMessage[]) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role === 'user') {
return getLastMessageText([messages[i]])
}
}
return ''
}
export const getResponseAndQueryFromMessageId = (
messages: UIMessage[],
messageId: string,
@@ -128,61 +76,6 @@ export interface ChatSessionOptions {
isIntegrationsSynced?: boolean
}
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
const getUserSystemPrompt = (
origin: ChatOrigin | undefined,
personalization: string,
) =>
origin === 'newtab'
? [personalization, NEWTAB_SYSTEM_PROMPT].filter(Boolean).join('\n\n')
: personalization
const buildRequestBrowserContext = ({
activeTab,
action,
enabledMcpServers,
customMcpServers,
}: {
activeTab?: chrome.tabs.Tab
action?: ChatAction
enabledMcpServers: Array<string | undefined>
customMcpServers: {
name: string
url?: string
}[]
}): ChatRequestBrowserContext | undefined => {
const browserContext: ChatRequestBrowserContext = {}
if (activeTab) {
browserContext.windowId = activeTab.windowId
browserContext.activeTab = {
id: activeTab.id,
url: activeTab.url,
title: activeTab.title,
}
}
if (action?.tabs?.length) {
browserContext.selectedTabs = action.tabs.map((tab) => ({
id: tab.id,
url: tab.url,
title: tab.title,
}))
}
const managedMcpServers = compact(enabledMcpServers)
if (managedMcpServers.length) {
browserContext.enabledMcpServers = managedMcpServers
}
if (customMcpServers.length) {
browserContext.customMcpServers = customMcpServers
}
return Object.keys(browserContext).length ? browserContext : undefined
}
export const useChatSession = (options?: ChatSessionOptions) => {
const {
selectedLlmProviderRef,
@@ -237,12 +130,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
conversationIdRef.current = conversationId
}, [conversationId])
const {
startTask: startExecutionTask,
syncFromMessages: syncExecutionHistory,
finishTask: finishExecutionTask,
} = useExecutionHistoryTracker()
const onClickLike = (messageId: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
messages,
@@ -277,7 +164,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
}
const modeRef = useRef<ChatMode>(mode)
const approvalJustRespondedRef = useRef(false)
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
const workingDirRef = useRef<string | undefined>(undefined)
const selectionMapRef = useRef<
@@ -342,12 +228,10 @@ export const useChatSession = (options?: ChatSessionOptions) => {
status,
stop,
error: chatError,
addToolApprovalResponse,
} = useChat({
transport: new DefaultChatTransport({
// Important: this chat logic is also used in apps/agent/lib/schedules/getChatServerResponse.ts for scheduled jobs. Make sure to keep them in sync for any future changes.
prepareSendMessagesRequest: async ({ messages }) => {
const provider =
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
const activeTabsList = await chrome.tabs.query({
active: true,
currentWindow: true,
@@ -356,24 +240,67 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const activeTabSelection = activeTab?.id
? (selectionMapRef.current[String(activeTab.id)] ?? null)
: null
const message = getLastMessageText(messages)
const provider =
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
const currentMode = modeRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
const lastUserMessage = getLastUserMessageText(messages)
const action = textToActionRef.current.get(lastUserMessage)
const requestBrowserContext = buildRequestBrowserContext({
activeTab,
action,
enabledMcpServers,
customMcpServers,
})
const getActionForMessage = (messageText: string) => {
return textToActionRef.current.get(messageText)
}
const action = getActionForMessage(message)
const browserContext: {
windowId?: number
activeTab?: {
id?: number
url?: string
title?: string
}
selectedTabs?: {
id?: number
url?: string
title?: string
}[]
enabledMcpServers?: string[]
customMcpServers?: {
name: string
url: string
}[]
} = {}
if (activeTab) {
browserContext.windowId = activeTab.windowId
browserContext.activeTab = {
id: activeTab.id,
url: activeTab.url,
title: activeTab.title,
}
}
if (action?.tabs?.length) {
browserContext.selectedTabs = action?.tabs?.map((tab) => ({
id: tab.id,
url: tab.url,
title: tab.title,
}))
}
if (enabledMcpServers.length) {
browserContext.enabledMcpServers = compact(enabledMcpServers)
}
if (customMcpServers.length) {
browserContext.customMcpServers = customMcpServers as {
name: string
url: string
}[]
}
const declinedApps = await declinedAppsStorage.getValue()
const allAclRules = await aclRulesStorage.getValue()
const enabledAclRules = allAclRules.filter((r) => r.enabled)
const approvalConfig = normalizeToolApprovalConfig(
await toolApprovalConfigStorage.getValue(),
)
const supportsArrayConversation = await Capabilities.supports(
Feature.PREVIOUS_CONVERSATION_ARRAY,
@@ -390,46 +317,37 @@ export const useChatSession = (options?: ChatSessionOptions) => {
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
: undefined
const userSystemPrompt = getUserSystemPrompt(
options?.origin,
personalizationRef.current,
)
const approvalResponses = extractApprovalResponses(messages)
if (approvalResponses) {
return {
api: `${agentUrlRef.current}/chat`,
body: buildChatRequestBody({
conversationId: conversationIdRef.current,
provider,
mode: currentMode,
browserContext: requestBrowserContext,
userSystemPrompt,
userWorkingDir: workingDirRef.current,
previousConversation,
declinedApps,
aclRules: enabledAclRules,
toolApprovalConfig: approvalConfig,
toolApprovalResponses: approvalResponses,
}),
}
}
const message = getLastMessageText(messages)
const result = {
api: `${agentUrlRef.current}/chat`,
body: buildChatRequestBody({
body: {
message,
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
conversationId: conversationIdRef.current,
provider,
model: provider?.modelId ?? 'default',
mode: currentMode,
browserContext: requestBrowserContext,
userSystemPrompt,
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
// Azure-specific
resourceName: provider?.resourceName,
// Bedrock-specific
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
// ChatGPT Pro (Codex)
reasoningEffort: provider?.reasoningEffort,
reasoningSummary: provider?.reasoningSummary,
browserContext,
origin: options?.origin ?? 'sidepanel',
userSystemPrompt: personalizationRef.current,
userWorkingDir: workingDirRef.current,
supportsImages: provider?.supportsImages,
previousConversation,
declinedApps,
aclRules: enabledAclRules,
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
selectedText: activeTabSelection?.text,
selectedTextSource: activeTabSelection
? {
@@ -437,8 +355,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
title: activeTabSelection.title,
}
: undefined,
toolApprovalConfig: approvalConfig,
}),
},
}
// Track which tab's selection was sent so we can clear it on success
@@ -448,20 +365,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return result
},
}),
sendAutomaticallyWhen: () => {
if (approvalJustRespondedRef.current) {
approvalJustRespondedRef.current = false
return true
}
return false
},
onFinish: async ({ message, isAbort, isError }) => {
await finishExecutionTask({
responseText: getLastMessageText([message]),
isAbort,
isError,
})
},
})
// Remove messages with empty parts (e.g. interrupted assistant responses)
@@ -539,8 +442,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
// Keep messagesRef in sync on every change (cheap ref assignment)
useEffect(() => {
messagesRef.current = messages
syncExecutionHistory(messages, status)
}, [messages, status, syncExecutionHistory])
}, [messages])
// Save conversation only after streaming completes — not on every token
const previousStatusRef = useRef(status)
@@ -583,69 +485,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
// Sync pending tool approvals to shared storage for the admin dashboard
useEffect(() => {
let isCancelled = false
const syncPendingApprovals = async () => {
const pending = extractPendingApprovals(
messages,
conversationIdRef.current,
)
const current = (await pendingToolApprovalsStorage.getValue()) ?? []
if (isCancelled) return
await pendingToolApprovalsStorage.setValue(
replacePendingApprovalsForConversation(
current,
conversationIdRef.current,
pending,
),
)
}
syncPendingApprovals()
return () => {
isCancelled = true
}
}, [messages])
// Watch for approval responses from the admin dashboard
// biome-ignore lint/correctness/useExhaustiveDependencies: only set up once
useEffect(() => {
const handleResponses = async (responses: ApprovalResponse[]) => {
if (!responses?.length) return
try {
for (const resp of responses) {
respondToToolApproval({
id: resp.approvalId,
approved: resp.approved,
reason: resp.reason,
})
}
const approvalIds = responses.map((resp) => resp.approvalId)
const currentResponses =
(await approvalResponsesStorage.getValue()) ?? []
const currentPending =
(await pendingToolApprovalsStorage.getValue()) ?? []
await approvalResponsesStorage.setValue(
removeApprovalResponsesById(currentResponses, approvalIds),
)
await pendingToolApprovalsStorage.setValue(
removePendingApprovalsById(currentPending, approvalIds),
)
} catch {
// Leave storage intact so the dashboard can retry
}
}
approvalResponsesStorage.getValue().then(handleResponses)
const unwatch = approvalResponsesStorage.watch(handleResponses)
return () => unwatch()
}, [])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{
@@ -653,17 +492,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
action?: ChatAction
} | null>(null)
const dispatchMessage = useCallback(
(text: string) => {
startExecutionTask({
conversationId: conversationIdRef.current,
promptText: text,
})
baseSendMessage({ text })
},
[baseSendMessage, startExecutionTask],
)
useEffect(() => {
isIntegrationsSyncedRef.current = isIntegrationsSynced
}, [isIntegrationsSynced])
@@ -681,9 +509,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return next
})
}
dispatchMessage(pending.text)
baseSendMessage({ text: pending.text })
}
}, [dispatchMessage, isIntegrationsSynced])
}, [isIntegrationsSynced, baseSendMessage])
const sendMessage = (params: { text: string; action?: ChatAction }) => {
track(MESSAGE_SENT_EVENT, {
@@ -706,7 +534,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return next
})
}
dispatchMessage(params.text)
baseSendMessage({ text: params.text })
}
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
@@ -732,15 +560,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return () => unwatch()
}, [])
const respondToToolApproval = (params: {
id: string
approved: boolean
reason?: string
}) => {
approvalJustRespondedRef.current = true
addToolApprovalResponse(params)
}
const handleSelectProvider = (provider: Provider) => {
const fullProvider = llmProviders.find((p) => p.id === provider.id)
track(PROVIDER_SELECTED_EVENT, {
@@ -763,7 +582,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const resetConversation = () => {
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
stop()
void finishExecutionTask({ isAbort: true })
setConversationId(crypto.randomUUID())
setMessages([])
setTextToAction(new Map())
@@ -798,6 +616,5 @@ export const useChatSession = (options?: ChatSessionOptions) => {
disliked,
onClickDislike,
conversationId,
addToolApprovalResponse: respondToToolApproval,
}
}

View File

@@ -1,164 +0,0 @@
import type { ChatStatus, UIMessage } from 'ai'
import { useCallback, useRef } from 'react'
import {
getResponsePreview,
normalizeExecutionSteps,
} from '@/lib/execution-history/normalize'
import { upsertConversationExecutionTask } from '@/lib/execution-history/storage'
import type {
ExecutionTaskRecord,
ExecutionTaskStatus,
} from '@/lib/execution-history/types'
import { sentry } from '@/lib/sentry/sentry'
interface StartExecutionTaskInput {
conversationId: string
promptText: string
}
interface FinishExecutionTaskInput {
responseText?: string
isAbort?: boolean
isError?: boolean
}
function createTask(input: StartExecutionTaskInput): ExecutionTaskRecord {
return {
id: crypto.randomUUID(),
conversationId: input.conversationId,
promptText: input.promptText,
startedAt: new Date().toISOString(),
status: 'running',
actionCount: 0,
approvalCount: 0,
deniedCount: 0,
errorCount: 0,
steps: [],
}
}
function getLastUserMessage(messages: UIMessage[]): UIMessage | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.role === 'user') {
return messages[index]
}
}
}
function getLastAssistantMessage(messages: UIMessage[]): UIMessage | undefined {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'assistant') {
return lastMessage
}
}
function getFinishedStatus(
input: FinishExecutionTaskInput,
): ExecutionTaskStatus {
if (input.isError) return 'failed'
if (input.isAbort) return 'stopped'
return 'completed'
}
export function useExecutionHistoryTracker() {
const activeTaskRef = useRef<ExecutionTaskRecord | null>(null)
const lastSavedHashRef = useRef('')
const writeQueueRef = useRef(Promise.resolve())
const persistTask = useCallback((task: ExecutionTaskRecord) => {
const taskHash = JSON.stringify(task)
if (taskHash === lastSavedHashRef.current) return
activeTaskRef.current = task
writeQueueRef.current = writeQueueRef.current
.then(async () => {
await upsertConversationExecutionTask(task)
lastSavedHashRef.current = taskHash
})
.catch((error) => {
sentry.captureException(error, {
extra: {
message: 'Failed to persist execution history task',
conversationId: task.conversationId,
taskId: task.id,
},
})
})
}, [])
const startTask = useCallback(
(input: StartExecutionTaskInput) => {
const task = createTask(input)
lastSavedHashRef.current = ''
persistTask(task)
return task.id
},
[persistTask],
)
const syncFromMessages = useCallback(
(messages: UIMessage[], _status: ChatStatus) => {
const activeTask = activeTaskRef.current
if (!activeTask) return
const promptMessage = getLastUserMessage(messages)
const assistantMessage = getLastAssistantMessage(messages)
const normalized = normalizeExecutionSteps({
assistantMessage,
previousSteps: activeTask.steps,
nowIso: new Date().toISOString(),
})
persistTask({
...activeTask,
promptMessageId: activeTask.promptMessageId ?? promptMessage?.id,
assistantMessageId:
normalized.assistantMessageId ?? activeTask.assistantMessageId,
responsePreview:
getResponsePreview(assistantMessage) || activeTask.responsePreview,
actionCount: normalized.actionCount,
approvalCount: normalized.approvalCount,
deniedCount: normalized.deniedCount,
errorCount: normalized.errorCount,
steps: normalized.steps,
})
},
[persistTask],
)
const finishTask = useCallback(
async (input: FinishExecutionTaskInput) => {
const activeTask = activeTaskRef.current
if (!activeTask) return
const responseText = input.responseText?.trim() || activeTask.responseText
const nextTask: ExecutionTaskRecord = {
...activeTask,
completedAt: new Date().toISOString(),
status: getFinishedStatus(input),
responseText,
responsePreview: responseText
? getResponsePreview({
parts: [{ type: 'text', text: responseText }],
} as Pick<UIMessage, 'parts'>)
: activeTask.responsePreview,
}
persistTask(nextTask)
activeTaskRef.current = null
},
[persistTask],
)
const clearActiveTask = useCallback(() => {
activeTaskRef.current = null
lastSavedHashRef.current = ''
}, [])
return {
startTask,
syncFromMessages,
finishTask,
clearActiveTask,
}
}

View File

@@ -1,7 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { storage } from '#imports'
export const aclRulesStorage = storage.defineItem<AclRule[]>(
'local:acl-rules',
{ fallback: [] },
)

View File

@@ -1,31 +0,0 @@
import { del, get, keys, set } from 'idb-keyval'
import type { AgentConversation } from './types'
const PREFIX = 'agent-conv:'
export async function saveConversation(conv: AgentConversation): Promise<void> {
await set(`${PREFIX}${conv.agentId}:${conv.sessionKey}`, conv)
}
export async function getLatestConversation(
agentId: string,
): Promise<AgentConversation | undefined> {
const allKeys = await keys()
const agentKeys = (allKeys as string[]).filter((k) =>
k.startsWith(`${PREFIX}${agentId}:`),
)
if (!agentKeys.length) return undefined
const conversations = await Promise.all(
agentKeys.map((k) => get<AgentConversation>(k)),
)
const valid = conversations.filter((c): c is AgentConversation => c != null)
return valid.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? undefined
}
export async function deleteConversation(
agentId: string,
sessionKey: string,
): Promise<void> {
await del(`${PREFIX}${agentId}:${sessionKey}`)
}

View File

@@ -1,53 +0,0 @@
export interface AssistantTextPart {
kind: 'text'
text: string
}
export interface AssistantThinkingPart {
kind: 'thinking'
text: string
done: boolean
}
export interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
export interface AssistantToolBatchPart {
kind: 'tool-batch'
tools: ToolEntry[]
}
export type AssistantPart =
| AssistantTextPart
| AssistantThinkingPart
| AssistantToolBatchPart
export interface AgentConversationTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
timestamp: number
}
export interface AgentConversation {
agentId: string
agentName: string
sessionKey: string
turns: AgentConversationTurn[]
createdAt: number
updatedAt: number
}
export interface AgentCardData {
agentId: string
name: string
model?: string
status: 'idle' | 'working' | 'error'
lastMessage?: string
lastMessageTimestamp?: number
}

View File

@@ -2,7 +2,6 @@ import { storage } from '@wxt-dev/storage'
import type { UIMessage } from 'ai'
import { useEffect, useState } from 'react'
import { useSessionInfo } from '../auth/sessionStorage'
import { removeConversationExecutionHistory } from '../execution-history/storage'
import { uploadConversationsToGraphql } from './uploadConversationsToGraphql'
const MAX_CONVERSATIONS = 50
@@ -43,7 +42,6 @@ export function useConversations() {
const removeConversation = async (id: string) => {
const current = (await conversationStorage.getValue()) ?? []
await conversationStorage.setValue(current.filter((c) => c.id !== id))
await removeConversationExecutionHistory(id)
}
const saveConversation = async (id: string, messages: UIMessage[]) => {
@@ -70,16 +68,11 @@ export function useConversations() {
messages,
lastMessagedAt: Date.now(),
}
const nextConversations = [newConversation, ...current]
const removedConversations = nextConversations.slice(MAX_CONVERSATIONS)
await conversationStorage.setValue(
nextConversations.slice(0, MAX_CONVERSATIONS),
)
await Promise.all(
removedConversations.map((conversation) =>
removeConversationExecutionHistory(conversation.id),
),
)
let updated = [newConversation, ...current]
if (updated.length > MAX_CONVERSATIONS) {
updated = updated.slice(0, MAX_CONVERSATIONS)
}
await conversationStorage.setValue(updated)
}
}

View File

@@ -1,161 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { UIMessage } from 'ai'
import {
getMessageText,
getResponsePreview,
normalizeExecutionSteps,
} from './normalize'
function asMessagePart(
part: Record<string, unknown>,
): UIMessage['parts'][number] {
return part as unknown as UIMessage['parts'][number]
}
function createAssistantMessage(parts: UIMessage['parts']): UIMessage {
return {
id: 'assistant-1',
role: 'assistant',
parts,
} as UIMessage
}
describe('normalizeExecutionSteps', () => {
it('filters nudge tools from the execution history', () => {
const message = createAssistantMessage([
asMessagePart({ type: 'text', text: 'I checked that for you.' }),
asMessagePart({
type: 'tool-suggest_schedule',
toolCallId: 'nudge-1',
state: 'output-available',
input: { scheduleType: 'daily' },
output: { suggestedName: 'Morning briefing' },
}),
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'output-available',
input: { ref_id: 'page-1' },
output: { pageId: 1 },
}),
])
const normalized = normalizeExecutionSteps({
assistantMessage: message,
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.assistantMessageId).toBe('assistant-1')
expect(normalized.actionCount).toBe(1)
expect(normalized.steps).toHaveLength(1)
expect(normalized.steps[0]).toMatchObject({
id: 'tool-1',
toolName: 'open',
state: 'output-available',
})
})
it('preserves the original start time when a tool step reaches a terminal state', () => {
const initialTimestamp = '2026-03-26T10:00:00.000Z'
const completedTimestamp = '2026-03-26T10:00:04.000Z'
const running = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'input-available',
input: { ref_id: 'page-1' },
}),
]),
nowIso: initialTimestamp,
})
const completed = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'output-available',
input: { ref_id: 'page-1' },
output: { title: 'Example Domain' },
}),
]),
previousSteps: running.steps,
nowIso: completedTimestamp,
})
expect(completed.steps[0]?.startedAt).toBe(initialTimestamp)
expect(completed.steps[0]?.completedAt).toBe(completedTimestamp)
})
it('uses a compact preview for completed tool output', () => {
const normalized = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-open',
toolCallId: 'tool-1',
state: 'output-available',
input: { ref_id: 'page-1' },
output: {
content: [
{
type: 'text',
text: 'Navigated to https://amazon.com. Additional context: page snapshot follows.',
},
],
},
}),
]),
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.steps[0]?.previewText).toBe('Completed successfully')
})
it('surfaces ACL blocks as a compact issue label', () => {
const normalized = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-click',
toolCallId: 'tool-1',
state: 'output-available',
input: { x: 10, y: 20 },
output: {
content: [
{
type: 'text',
text: "Action blocked by ACL rule: 'add to cart'. The element on this page is restricted.",
},
],
},
}),
]),
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.steps[0]?.previewText).toBe('Blocked by ACL rule')
})
})
describe('execution history text helpers', () => {
it('joins text parts into a single response body', () => {
const text = getMessageText({
parts: [
asMessagePart({ type: 'text', text: 'First line' }),
asMessagePart({ type: 'text', text: 'Second line' }),
],
})
expect(text).toBe('First line\n\nSecond line')
})
it('truncates long response previews', () => {
const preview = getResponsePreview({
parts: [asMessagePart({ type: 'text', text: 'a'.repeat(220) })],
})
expect(preview).toHaveLength(180)
expect(preview.endsWith('...')).toBe(true)
})
})

View File

@@ -1,215 +0,0 @@
import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai'
import type {
ExecutionStepApproval,
ExecutionStepRecord,
ExecutionStepState,
} from './types'
const NUDGE_TOOL_NAMES = new Set(['suggest_schedule', 'suggest_app_connection'])
const TERMINAL_STEP_STATES = new Set<ExecutionStepState>([
'output-available',
'output-error',
'output-denied',
])
const MAX_PREVIEW_CHARS = 180
type ToolLikePart = ToolUIPart | DynamicToolUIPart
function truncateText(value: string): string {
if (value.length <= MAX_PREVIEW_CHARS) return value
return `${value.slice(0, MAX_PREVIEW_CHARS - 3)}...`
}
function stringifyValue(value: unknown): string {
if (typeof value === 'string') return value
if (value == null) return ''
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function getNestedText(value: unknown, depth = 0): string | undefined {
if (depth > 5 || value == null) return undefined
if (typeof value === 'string') {
const text = normalizeText(value)
return text || undefined
}
if (Array.isArray(value)) {
for (const item of value) {
const text = getNestedText(item, depth + 1)
if (text) return text
}
return undefined
}
if (typeof value !== 'object') return undefined
const record = value as Record<string, unknown>
for (const key of ['text', 'message', 'reason', 'content']) {
const text = getNestedText(record[key], depth + 1)
if (text) return text
}
for (const nestedValue of Object.values(record)) {
const text = getNestedText(nestedValue, depth + 1)
if (text) return text
}
return undefined
}
function getCompactIssueLabel(value?: string): string | undefined {
if (!value) return undefined
if (value.includes('Action blocked by ACL rule')) {
return 'Blocked by ACL rule'
}
return undefined
}
function getToolName(part: ToolLikePart): string {
if (part.type === 'dynamic-tool') {
return part.toolName
}
return part.type.replace('tool-', '')
}
function isToolPart(part: UIMessage['parts'][number]): part is ToolLikePart {
return part.type === 'dynamic-tool' || part.type.startsWith('tool-')
}
function isExecutionToolPart(
part: UIMessage['parts'][number],
): part is ToolLikePart {
if (!isToolPart(part)) return false
return !NUDGE_TOOL_NAMES.has(getToolName(part))
}
function getPreviewText(part: ToolLikePart): string {
if (part.state === 'approval-requested') {
return 'Waiting for approval'
}
if (part.state === 'approval-responded') {
return part.approval?.approved === false
? 'Approval rejected'
: 'Approval granted'
}
if (part.state === 'output-denied') {
return getCompactIssueLabel(part.approval?.reason) ?? 'Action denied'
}
if (part.state === 'output-error') {
return getCompactIssueLabel(part.errorText) ?? 'Action failed'
}
if (part.state === 'output-available') {
const preview =
getCompactIssueLabel(getNestedText(part.output)) ??
getCompactIssueLabel(stringifyValue(part.output))
return preview ?? 'Completed successfully'
}
if (part.state === 'input-available') {
return 'Action running'
}
return 'Preparing action'
}
function getApproval(part: ToolLikePart): ExecutionStepApproval | undefined {
return part.approval
? {
id: part.approval.id,
approved: part.approval.approved,
reason: part.approval.reason,
}
: undefined
}
function getCompletedAt(
existingStep: ExecutionStepRecord | undefined,
state: ExecutionStepState,
nowIso: string,
): string | undefined {
if (existingStep?.completedAt) return existingStep.completedAt
if (!TERMINAL_STEP_STATES.has(state)) return undefined
return nowIso
}
function createStepRecord(
part: ToolLikePart,
order: number,
nowIso: string,
existingStep?: ExecutionStepRecord,
): ExecutionStepRecord {
const state = part.state as ExecutionStepState
return {
id: part.toolCallId,
toolName: getToolName(part),
order,
state,
startedAt: existingStep?.startedAt ?? nowIso,
completedAt: getCompletedAt(existingStep, state, nowIso),
input: part.input,
output: 'output' in part ? part.output : undefined,
errorText: 'errorText' in part ? part.errorText : undefined,
previewText: getPreviewText(part),
approval: getApproval(part),
}
}
export function getMessageText(
message?: Pick<UIMessage, 'parts'> | null,
): string {
if (!message) return ''
return message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n\n')
.trim()
}
export function getResponsePreview(message?: Pick<UIMessage, 'parts'> | null) {
return truncateText(getMessageText(message))
}
export function normalizeExecutionSteps(args: {
assistantMessage?: UIMessage | null
previousSteps?: ExecutionStepRecord[]
nowIso: string
}) {
const { assistantMessage, previousSteps = [], nowIso } = args
const previousStepsById = new Map(
previousSteps.map((step) => [step.id, step]),
)
const steps = assistantMessage
? assistantMessage.parts.flatMap((part, index) => {
if (!isExecutionToolPart(part)) return []
const existingStep = previousStepsById.get(part.toolCallId)
return [createStepRecord(part, index, nowIso, existingStep)]
})
: []
return {
assistantMessageId: assistantMessage?.id,
steps,
actionCount: steps.length,
approvalCount: steps.filter((step) => step.approval).length,
deniedCount: steps.filter((step) => step.state === 'output-denied').length,
errorCount: steps.filter((step) => step.state === 'output-error').length,
}
}

View File

@@ -1,146 +0,0 @@
import { storage } from '@wxt-dev/storage'
import { useEffect, useState } from 'react'
import type {
ConversationExecutionHistory,
ExecutionHistoryByConversation,
ExecutionTaskRecord,
} from './types'
export const executionHistoryStorage =
storage.defineItem<ExecutionHistoryByConversation>(
'local:executionHistoryByConversation',
{
fallback: {},
version: 1,
},
)
function upsertTaskInHistory(
history: ConversationExecutionHistory,
task: ExecutionTaskRecord,
): ConversationExecutionHistory {
const existingIndex = history.tasks.findIndex((item) => item.id === task.id)
if (existingIndex === -1) {
return {
...history,
updatedAt: Date.now(),
tasks: [...history.tasks, task],
}
}
const nextTasks = [...history.tasks]
nextTasks[existingIndex] = task
return {
...history,
updatedAt: Date.now(),
tasks: nextTasks,
}
}
function createConversationHistory(
conversationId: string,
): ConversationExecutionHistory {
return {
conversationId,
updatedAt: Date.now(),
tasks: [],
}
}
export async function upsertConversationExecutionTask(
task: ExecutionTaskRecord,
): Promise<void> {
const current = (await executionHistoryStorage.getValue()) ?? {}
const history =
current[task.conversationId] ??
createConversationHistory(task.conversationId)
await executionHistoryStorage.setValue({
...current,
[task.conversationId]: upsertTaskInHistory(history, task),
})
}
export async function getConversationExecutionHistory(
conversationId: string,
): Promise<ConversationExecutionHistory | null> {
const current = (await executionHistoryStorage.getValue()) ?? {}
return current[conversationId] ?? null
}
export async function getExecutionHistoryByConversation(): Promise<ExecutionHistoryByConversation> {
return (await executionHistoryStorage.getValue()) ?? {}
}
export async function removeConversationExecutionHistory(
conversationId: string,
): Promise<void> {
const current = (await executionHistoryStorage.getValue()) ?? {}
if (!(conversationId in current)) return
const { [conversationId]: _removed, ...rest } = current
await executionHistoryStorage.setValue(rest)
}
export async function removeConversationExecutionTask(args: {
conversationId: string
taskId: string
}): Promise<void> {
const current = (await executionHistoryStorage.getValue()) ?? {}
const history = current[args.conversationId]
if (!history) return
const nextTasks = history.tasks.filter((task) => task.id !== args.taskId)
if (nextTasks.length === history.tasks.length) return
if (nextTasks.length === 0) {
const { [args.conversationId]: _removed, ...rest } = current
await executionHistoryStorage.setValue(rest)
return
}
await executionHistoryStorage.setValue({
...current,
[args.conversationId]: {
...history,
updatedAt: Date.now(),
tasks: nextTasks,
},
})
}
export function useConversationExecutionHistory(conversationId?: string) {
const [history, setHistory] = useState<ConversationExecutionHistory | null>(
null,
)
useEffect(() => {
if (!conversationId) {
setHistory(null)
return
}
getConversationExecutionHistory(conversationId).then(setHistory)
const unwatch = executionHistoryStorage.watch((nextValue) => {
setHistory(nextValue?.[conversationId] ?? null)
})
return () => unwatch()
}, [conversationId])
return history
}
export function useExecutionHistoryByConversation() {
const [historyByConversation, setHistoryByConversation] =
useState<ExecutionHistoryByConversation>({})
useEffect(() => {
getExecutionHistoryByConversation().then(setHistoryByConversation)
const unwatch = executionHistoryStorage.watch((nextValue) => {
setHistoryByConversation(nextValue ?? {})
})
return () => unwatch()
}, [])
return historyByConversation
}

View File

@@ -1,64 +0,0 @@
export type ExecutionTaskStatus =
| 'running'
| 'completed'
| 'stopped'
| 'failed'
| 'interrupted'
export type ExecutionStepState =
| 'input-streaming'
| 'input-available'
| 'approval-requested'
| 'approval-responded'
| 'output-available'
| 'output-error'
| 'output-denied'
export interface ExecutionStepApproval {
id: string
approved?: boolean
reason?: string
}
export interface ExecutionStepRecord {
id: string
toolName: string
order: number
state: ExecutionStepState
startedAt: string
completedAt?: string
input?: unknown
output?: unknown
errorText?: string
previewText: string
approval?: ExecutionStepApproval
}
export interface ExecutionTaskRecord {
id: string
conversationId: string
promptText: string
promptMessageId?: string
assistantMessageId?: string
startedAt: string
completedAt?: string
status: ExecutionTaskStatus
responseText?: string
responsePreview?: string
actionCount: number
approvalCount: number
deniedCount: number
errorCount: number
steps: ExecutionStepRecord[]
}
export interface ConversationExecutionHistory {
conversationId: string
updatedAt: number
tasks: ExecutionTaskRecord[]
}
export type ExecutionHistoryByConversation = Record<
string,
ConversationExecutionHistory
>

View File

@@ -1,84 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
import { buildChatRequestBody } from './buildChatRequestBody'
const provider: LlmProviderConfig = {
id: 'browseros',
type: 'browseros',
name: 'BrowserOS',
modelId: 'browseros-auto',
supportsImages: true,
contextWindow: 200000,
temperature: 0,
createdAt: 0,
updatedAt: 0,
}
describe('buildChatRequestBody', () => {
it('preserves approval config and browser context on approval resumes', () => {
const toolApprovalConfig: ToolApprovalConfig = {
categories: {
input: true,
navigation: true,
observation: true,
screenshots: true,
scripts: true,
'data-modification': true,
assistant: true,
},
}
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
mode: 'agent',
browserContext: {
windowId: 2,
activeTab: {
id: 10,
url: 'https://amazon.com',
title: 'Amazon',
},
enabledMcpServers: ['slack'],
},
userSystemPrompt: 'Stay in the current tab.',
toolApprovalConfig,
toolApprovalResponses: [
{
approvalId: 'approval-1',
approved: true,
},
],
})
expect(body.toolApprovalConfig).toEqual(toolApprovalConfig)
expect(body.browserContext).toEqual({
windowId: 2,
activeTab: {
id: 10,
url: 'https://amazon.com',
title: 'Amazon',
},
enabledMcpServers: ['slack'],
})
expect(body.toolApprovalResponses).toEqual([
{
approvalId: 'approval-1',
approved: true,
},
])
})
it('omits empty approval configs from requests', () => {
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
toolApprovalConfig: {
categories: {},
},
})
expect(body.toolApprovalConfig).toBeUndefined()
})
})

View File

@@ -1,115 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
export interface ApprovalResponseData {
approvalId: string
approved: boolean
reason?: string
}
export interface ChatHistoryEntry {
role: 'user' | 'assistant'
content: string
}
export interface ChatRequestBrowserContext {
windowId?: number
activeTab?: {
id?: number
url?: string
title?: string
}
selectedTabs?: {
id?: number
url?: string
title?: string
}[]
enabledMcpServers?: string[]
customMcpServers?: {
name: string
url?: string
}[]
}
interface ChatRequestBodyParams {
conversationId: string
provider: LlmProviderConfig
message?: string
mode?: ChatMode
browserContext?: ChatRequestBrowserContext
userSystemPrompt?: string
userWorkingDir?: string
supportsImages?: boolean
previousConversation?: ChatHistoryEntry[] | string
declinedApps?: string[]
aclRules?: AclRule[]
selectedText?: string
selectedTextSource?: {
url: string
title: string
}
toolApprovalConfig?: ToolApprovalConfig
toolApprovalResponses?: ApprovalResponseData[]
isScheduledTask?: boolean
}
export const toRequestToolApprovalConfig = (
approvalConfig?: ToolApprovalConfig,
): ToolApprovalConfig | undefined => {
if (!approvalConfig) return undefined
return Object.values(approvalConfig.categories).some(Boolean)
? approvalConfig
: undefined
}
export const buildChatRequestBody = ({
conversationId,
provider,
message = '',
mode,
browserContext,
userSystemPrompt,
userWorkingDir,
supportsImages,
previousConversation,
declinedApps,
aclRules,
selectedText,
selectedTextSource,
toolApprovalConfig,
toolApprovalResponses,
isScheduledTask,
}: ChatRequestBodyParams) => ({
message,
provider: provider.type,
providerType: provider.type,
providerName: provider.name,
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
conversationId,
model: provider.modelId ?? 'default',
mode,
contextWindowSize: provider.contextWindow,
temperature: provider.temperature,
resourceName: provider.resourceName,
accessKeyId: provider.accessKeyId,
secretAccessKey: provider.secretAccessKey,
region: provider.region,
sessionToken: provider.sessionToken,
reasoningEffort: provider.reasoningEffort,
reasoningSummary: provider.reasoningSummary,
browserContext,
userSystemPrompt,
userWorkingDir,
supportsImages: supportsImages ?? provider.supportsImages,
previousConversation,
declinedApps: declinedApps?.length ? declinedApps : undefined,
aclRules: aclRules?.length ? aclRules : undefined,
selectedText,
selectedTextSource,
toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig),
toolApprovalResponses,
isScheduledTask,
})

View File

@@ -8,7 +8,6 @@ import {
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody'
import { personalizationStorage } from '../personalization/personalizationStorage'
import { scheduleSystemPrompt } from './scheduleSystemPrompt'
import type { ToolCallExecution } from './scheduleTypes'
@@ -113,31 +112,42 @@ export async function getChatServerResponse(
headers: {
'Content-Type': 'application/json',
},
// Important: this chat logic is also used in apps/agent/entrypoints/sidepanel/index/useChatSession.ts for sidepanel conversation. Make sure to keep them in sync for any future changes.
body: JSON.stringify({
messages: [{ role: 'user', content: request.message }],
...buildChatRequestBody({
message: request.message,
conversationId,
provider,
mode: request.mode ?? 'agent',
browserContext:
request.activeTab ||
request.windowId ||
enabledMcpServers.length ||
customMcpServers.length
? {
windowId: request.windowId,
activeTab: request.activeTab,
enabledMcpServers:
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
customMcpServers:
customMcpServers.length > 0 ? customMcpServers : undefined,
}
: undefined,
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
supportsImages: provider.supportsImages,
isScheduledTask: true,
}),
message: request.message,
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
conversationId,
model: provider?.modelId ?? 'default',
mode: request.mode ?? 'agent',
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
resourceName: provider?.resourceName,
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
browserContext:
request.activeTab ||
request.windowId ||
enabledMcpServers.length ||
customMcpServers.length
? {
windowId: request.windowId,
activeTab: request.activeTab,
enabledMcpServers:
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
customMcpServers:
customMcpServers.length > 0 ? customMcpServers : undefined,
}
: undefined,
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
isScheduledTask: true,
supportsImages: provider?.supportsImages,
}),
})

View File

@@ -1,71 +0,0 @@
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
export function parseSSELines<T>(buffer: string): {
events: T[]
remainder: string
} {
const lines = buffer.split('\n')
const remainder = lines.pop() ?? ''
const events: T[] = []
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (payload === '[DONE]') continue
try {
events.push(JSON.parse(payload) as T)
} catch {}
}
return { events, remainder }
}
export async function consumeSSEStream<T>(
response: Response,
onEvent: (event: T) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
const abortReader = () => {
void reader.cancel()
}
signal?.addEventListener('abort', abortReader, { once: true })
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const { events, remainder } = parseSSELines<T>(buffer)
buffer = remainder
for (const event of events) {
onEvent(event)
}
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) return
throw error
} finally {
signal?.removeEventListener('abort', abortReader)
const trailing = decoder.decode()
if (trailing) {
buffer += trailing
}
if (buffer) {
const { events } = parseSSELines<T>(buffer)
for (const event of events) {
onEvent(event)
}
}
}
}

View File

@@ -1,84 +0,0 @@
import type { UIMessage } from 'ai'
import type { ApprovalResponse, PendingApproval } from './approval-sync-storage'
export function extractPendingApprovals(
messages: UIMessage[],
conversationId: string,
timestamp = Date.now(),
): PendingApproval[] {
const pending: PendingApproval[] = []
for (const msg of messages) {
for (const part of msg.parts) {
const toolPart = part as {
type?: string
state?: string
toolCallId?: string
input?: Record<string, unknown>
approval?: { id: string }
}
if (
toolPart.state === 'approval-requested' &&
toolPart.approval?.id &&
toolPart.toolCallId
) {
pending.push({
approvalId: toolPart.approval.id,
toolCallId: toolPart.toolCallId,
toolName: (toolPart.type ?? '').replace('tool-', ''),
input: toolPart.input ?? {},
conversationId,
timestamp,
})
}
}
}
return pending
}
export function replacePendingApprovalsForConversation(
existing: PendingApproval[],
conversationId: string,
next: PendingApproval[],
): PendingApproval[] {
const existingByApprovalId = new Map(
existing.map((item) => [item.approvalId, item]),
)
const preserved = next.map((item) => {
const current = existingByApprovalId.get(item.approvalId)
return current ? { ...item, timestamp: current.timestamp } : item
})
return [
...existing.filter((item) => item.conversationId !== conversationId),
...preserved,
]
}
export function queueApprovalResponse(
existing: ApprovalResponse[],
response: ApprovalResponse,
): ApprovalResponse[] {
return [
...existing.filter((item) => item.approvalId !== response.approvalId),
response,
]
}
export function removePendingApprovalsById(
existing: PendingApproval[],
approvalIds: string[],
): PendingApproval[] {
const ids = new Set(approvalIds)
return existing.filter((item) => !ids.has(item.approvalId))
}
export function removeApprovalResponsesById(
existing: ApprovalResponse[],
approvalIds: string[],
): ApprovalResponse[] {
const ids = new Set(approvalIds)
return existing.filter((item) => !ids.has(item.approvalId))
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { UIMessage } from 'ai'
import {
extractPendingApprovals,
queueApprovalResponse,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from './approval-sync-helpers'
describe('approval sync storage helpers', () => {
it('extracts pending approvals from assistant tool parts', () => {
const messages = [
{
id: 'assistant-1',
role: 'assistant',
parts: [
{
type: 'tool-click',
state: 'approval-requested',
toolCallId: 'tool-1',
input: { selector: '#buy-now' },
approval: { id: 'approval-1' },
},
],
},
] as UIMessage[]
expect(extractPendingApprovals(messages, 'conversation-1', 123)).toEqual([
{
approvalId: 'approval-1',
toolCallId: 'tool-1',
toolName: 'click',
input: { selector: '#buy-now' },
conversationId: 'conversation-1',
timestamp: 123,
},
])
})
it('replaces pending approvals for one conversation without clearing others', () => {
const existing = [
{
approvalId: 'approval-a',
toolCallId: 'tool-a',
toolName: 'click',
input: {},
conversationId: 'conversation-a',
timestamp: 1,
},
{
approvalId: 'approval-b',
toolCallId: 'tool-b',
toolName: 'navigate_page',
input: {},
conversationId: 'conversation-b',
timestamp: 2,
},
]
expect(
replacePendingApprovalsForConversation(existing, 'conversation-a', []),
).toEqual([existing[1]])
})
it('queues and removes approval responses by approval id', () => {
const queued = queueApprovalResponse(
[
{
approvalId: 'approval-a',
approved: true,
timestamp: 1,
},
],
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
)
expect(queued).toEqual([
{
approvalId: 'approval-a',
approved: true,
timestamp: 1,
},
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
])
expect(removeApprovalResponsesById(queued, ['approval-a'])).toEqual([
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
])
})
it('removes only handled pending approvals', () => {
const pending = [
{
approvalId: 'approval-a',
toolCallId: 'tool-a',
toolName: 'click',
input: {},
conversationId: 'conversation-a',
timestamp: 1,
},
{
approvalId: 'approval-b',
toolCallId: 'tool-b',
toolName: 'fill',
input: {},
conversationId: 'conversation-b',
timestamp: 2,
},
]
expect(removePendingApprovalsById(pending, ['approval-b'])).toEqual([
pending[0],
])
})
})

View File

@@ -1,47 +0,0 @@
import { storage } from '@wxt-dev/storage'
export {
extractPendingApprovals,
queueApprovalResponse,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from './approval-sync-helpers'
export interface PendingApproval {
approvalId: string
toolCallId: string
toolName: string
input: Record<string, unknown>
conversationId: string
timestamp: number
}
export interface ApprovalResponse {
approvalId: string
approved: boolean
reason?: string
timestamp: number
}
export interface ToolExecutionLogEntry {
toolCallId: string
toolName: string
status: 'auto-allowed' | 'approved' | 'denied' | 'error'
conversationId: string
timestamp: number
input?: Record<string, unknown>
}
export const pendingToolApprovalsStorage = storage.defineItem<
PendingApproval[]
>('local:pending-tool-approvals', { fallback: [] })
export const approvalResponsesStorage = storage.defineItem<ApprovalResponse[]>(
'local:approval-responses',
{ fallback: [] },
)
export const toolExecutionLogStorage = storage.defineItem<
ToolExecutionLogEntry[]
>('local:tool-execution-log', { fallback: [] })

View File

@@ -1,38 +0,0 @@
import { storage } from '@wxt-dev/storage'
import type { ToolApprovalCategoryId, ToolApprovalConfig } from './types'
export const toolApprovalConfigStorage = storage.defineItem<ToolApprovalConfig>(
'local:tool-approval-config',
{
fallback: {
categories: {},
},
},
)
const LEGACY_ALL_CATEGORY_IDS: ToolApprovalCategoryId[] = [
'input',
'navigation',
'screenshots',
'scripts',
'data-modification',
]
const NEW_CATEGORY_IDS: ToolApprovalCategoryId[] = ['observation', 'assistant']
export function normalizeToolApprovalConfig(
config: ToolApprovalConfig,
): ToolApprovalConfig {
const categories = { ...config.categories }
const shouldMigrateLegacyAll =
LEGACY_ALL_CATEGORY_IDS.every((id) => categories[id] === true) &&
NEW_CATEGORY_IDS.every((id) => categories[id] === undefined)
if (shouldMigrateLegacyAll) {
for (const id of NEW_CATEGORY_IDS) {
categories[id] = true
}
}
return { categories }
}

View File

@@ -1,7 +0,0 @@
export {
TOOL_APPROVAL_CATEGORIES as TOOL_CATEGORIES,
TOOL_APPROVAL_CATEGORIES,
type ToolApprovalCategory,
type ToolApprovalCategoryId,
type ToolApprovalConfig,
} from '@browseros/shared/constants/tool-approval'

View File

@@ -20,7 +20,6 @@
"dependencies": {
"@ai-sdk/react": "^3.0.96",
"@browseros/server": "workspace:*",
"@browseros/shared": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons": "^2.44.0",
"@mdxeditor/editor": "^3.52.4",
@@ -52,9 +51,6 @@
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/storage": "^1.2.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.9.3",
"ai": "^6.0.94",
"better-auth": "^1.4.17",

View File

@@ -221,37 +221,6 @@
background-clip: padding-box;
}
.agent-terminal-shell .xterm {
height: 100%;
}
.agent-terminal-shell .xterm-viewport {
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: oklch(from var(--border) l c h / 0.45) transparent;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-track {
background: transparent;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-thumb {
background: oklch(from var(--border) l c h / 0.45);
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: oklch(from var(--border) l c h / 0.7);
background-clip: padding-box;
}
/* Custom animation keyframes for minimal, elegant hero animations */
@keyframes float {
0%,

View File

@@ -5,8 +5,7 @@
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./*"],
"@browseros/shared/*": ["../../packages/shared/src/*"]
"@/*": ["./*"]
},
"plugins": [
{

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.83",
"version": "0.0.82",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",
@@ -70,7 +70,6 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/provider": "^3.0.8",
"@browseros-ai/agent-sdk": "workspace:*",
"@huggingface/transformers": "^3.4.0",
"@browseros/cdp-protocol": "workspace:*",
"@browseros/shared": "workspace:*",
"@google/gemini-cli-core": "^0.16.0",

View File

@@ -1,37 +0,0 @@
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
ports:
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
environment:
- HOME=/home/node
- NODE_ENV=production
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
- OPENCLAW_GATEWAY_BIND=lan
- TZ=${TZ}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
extra_hosts:
- "host.containers.internal:host-gateway"
command:
- node
- dist/index.js
- gateway
- --bind
- lan
- --port
- "18789"
- --allow-unconfigured
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped

View File

@@ -6,7 +6,6 @@ import type {
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import type { AclRule } from '@browseros/shared/types/acl'
import {
type LanguageModel,
type ModelMessage,
@@ -24,7 +23,6 @@ import { isSoulBootstrap, readSoul } from '../lib/soul'
import { buildSkillsCatalog } from '../skills/catalog'
import { loadSkills } from '../skills/loader'
import { buildFilesystemToolSet } from '../tools/filesystem/build-toolset'
import type { ToolContext } from '../tools/framework'
import { buildMemoryToolSet } from '../tools/memory/build-toolset'
import type { ToolRegistry } from '../tools/tool-registry'
import { CHAT_MODE_ALLOWED_TOOLS } from './chat-mode'
@@ -48,7 +46,6 @@ export interface AiSdkAgentConfig {
klavisClient?: KlavisClient
browserosId?: string
aiSdkDevtoolsEnabled?: boolean
aclRules?: AclRule[]
}
export class AiSdkAgent {
@@ -58,7 +55,6 @@ export class AiSdkAgent {
private _mcpClients: Array<{ close(): Promise<void> }>,
private conversationId: string,
private _toolNames: Set<string>,
private toolContext: ToolContext,
) {}
/** Tool names registered on this agent — used to sanitize messages during session rebuilds. */
@@ -103,19 +99,14 @@ export class AiSdkAgent {
// Build browser tools from the unified tool registry
const originPageId = config.browserContext?.activeTab?.pageId
const toolContext: ToolContext = {
browser: config.browser,
directories: { workingDir: config.resolvedConfig.workingDir },
session: {
const allBrowserTools = buildBrowserToolSet(
config.registry,
config.browser,
config.resolvedConfig.workingDir,
{
origin: config.resolvedConfig.origin,
originPageId,
},
aclRules: config.aclRules,
}
const allBrowserTools = buildBrowserToolSet(
config.registry,
toolContext,
config.resolvedConfig.toolApprovalConfig,
)
const browserTools = config.resolvedConfig.chatMode
? Object.fromEntries(
@@ -286,7 +277,6 @@ export class AiSdkAgent {
clients,
config.resolvedConfig.conversationId,
new Set(Object.keys(tools)),
toolContext,
)
}
@@ -310,10 +300,6 @@ export class AiSdkAgent {
})
}
updateAclRules(rules?: AclRule[]): void {
this.toolContext.aclRules = rules
}
async dispose(): Promise<void> {
for (const client of this._mcpClients) {
await client.close().catch(() => {})

View File

@@ -11,8 +11,6 @@ export interface AgentSession {
mcpServerKey?: string
/** Workspace directory when the session was created, for change detection. */
workingDir?: string
/** Tool approval category key for change detection. */
approvalConfigKey?: string
}
export class SessionStore {

View File

@@ -1,6 +1,6 @@
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider'
import type { ToolApprovalConfig } from '@browseros/shared/constants/tool-approval'
import { type ToolSet, tool } from 'ai'
import type { Browser } from '../browser/browser'
import { logger } from '../lib/logger'
import { metrics } from '../lib/metrics'
import { executeTool, type ToolContext } from '../tools/framework'
@@ -35,29 +35,23 @@ function contentToModelOutput(
}
}
export function getApprovedBrowserToolNames(
registry: ToolRegistry,
approvalConfig?: ToolApprovalConfig,
): string[] {
if (!approvalConfig) return []
return registry
.all()
.filter((def) => approvalConfig.categories[def.approvalCategory] === true)
.map((def) => def.name)
}
export function buildBrowserToolSet(
registry: ToolRegistry,
ctx: ToolContext,
approvalConfig?: ToolApprovalConfig,
browser: Browser,
workingDir: string | undefined,
session?: { origin?: 'sidepanel' | 'newtab'; originPageId?: number },
): ToolSet {
const toolSet: ToolSet = {}
const ctx: ToolContext = {
browser,
directories: { workingDir },
session,
}
for (const def of registry.all()) {
toolSet[def.name] = tool({
description: def.description,
inputSchema: def.input,
needsApproval: approvalConfig?.categories[def.approvalCategory] === true,
execute: async (params) => {
const startTime = performance.now()
try {

View File

@@ -3,7 +3,6 @@
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ToolApprovalConfig } from '@browseros/shared/constants/tool-approval'
import type { LLMProvider } from '@browseros/shared/schemas/llm'
export interface ProviderConfig {
@@ -51,6 +50,4 @@ export interface ResolvedAgentConfig {
origin?: 'sidepanel' | 'newtab'
/** BrowserOS installation ID for credit-based tracking. */
browserosId?: string
/** Tool approval configuration — which categories require human approval. */
toolApprovalConfig?: ToolApprovalConfig
}

View File

@@ -1,477 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* HTTP routes for OpenClaw agent management.
* Thin layer delegating to OpenClawService.
*/
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { getOpenClawDir } from '../../lib/browseros-dir'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import {
getOpenClawService,
type OpenClawAgentEntry,
} from '../services/openclaw/openclaw-service'
import { OpenClawProgramMaterializer } from '../services/openclaw/program-materializer'
import { OpenClawProgramStorage } from '../services/openclaw/program-storage'
import {
validateCreateProgramInput,
validateUpdateProgramInput,
} from '../services/openclaw/program-validation'
function isValidBoundaryMode(
value: unknown,
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
return value === 'allow' || value === 'ask' || value === 'block'
}
function isValidCustomRoleBoundary(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const boundary = value as Record<string, unknown>
return (
typeof boundary.key === 'string' &&
typeof boundary.label === 'string' &&
typeof boundary.description === 'string' &&
isValidBoundaryMode(boundary.defaultMode)
)
}
const openclawProgramStorage = new OpenClawProgramStorage(getOpenClawDir())
const openclawProgramMaterializer = new OpenClawProgramMaterializer(
getOpenClawDir(),
openclawProgramStorage,
)
async function findOpenClawAgent(
agentId: string,
): Promise<OpenClawAgentEntry | null> {
const agents = await getOpenClawService().listAgents()
return agents.find((agent) => agent.agentId === agentId) ?? null
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
const status = await getOpenClawService().getStatus()
return c.json(status)
})
.post('/setup', async (c) => {
const body = await c.req.json<{
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
try {
const logs: string[] = []
await getOpenClawService().setup(body, (msg) => logs.push(msg))
const agents = await getOpenClawService().listAgents()
return c.json(
{
status: 'running',
port: OPENCLAW_GATEWAY_PORT,
agents: agents.map((a) => ({
agentId: a.agentId,
name: a.name,
status: 'running',
})),
logs,
},
201,
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('Podman is not available')) {
return c.json({ error: message }, 503)
}
return c.json({ error: message }, 500)
}
})
.post('/start', async (c) => {
try {
await getOpenClawService().start()
return c.json({ status: 'running' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/stop', async (c) => {
try {
await getOpenClawService().stop()
return c.json({ status: 'stopped' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/restart', async (c) => {
try {
await getOpenClawService().restart()
return c.json({ status: 'running' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/reconnect', async (c) => {
try {
await getOpenClawService().reconnectControlPlane()
return c.json({ status: 'connected' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents', async (c) => {
try {
const agents = await getOpenClawService().listAgents()
return c.json({ agents })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const programs = await openclawProgramStorage.listPrograms(agent.name)
return c.json({ programs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateCreateProgramInput(await c.req.json())
const program = await openclawProgramStorage.createProgram(agent, input)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ program }, 201)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.patch('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateUpdateProgramInput(await c.req.json())
const program = await openclawProgramStorage.updateProgram(
agent.name,
c.req.param('programId'),
input,
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ program })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid') ||
message.includes('At least one')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.delete('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const deleted = await openclawProgramStorage.deleteProgram(
agent.name,
c.req.param('programId'),
)
if (!deleted) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
return c.json({ success: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/program-runs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const runs = await openclawProgramStorage.listRuns(agent.name)
return c.json({ runs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
id: role.id,
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps: role.recommendedApps,
boundaries: role.boundaries,
defaultAgentName: role.defaultAgentName,
})),
})
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (body.roleId && body.customRole) {
return c.json(
{ error: 'Provide either roleId or customRole, not both' },
400,
)
}
if (
body.customRole &&
(!body.customRole.name?.trim() ||
!body.customRole.shortDescription?.trim() ||
!body.customRole.longDescription?.trim())
) {
return c.json(
{
error:
'Custom roles require name, shortDescription, and longDescription',
},
400,
)
}
if (
body.customRole &&
(!Array.isArray(body.customRole.recommendedApps) ||
!Array.isArray(body.customRole.boundaries))
) {
return c.json(
{
error: 'Custom roles require recommendedApps and boundaries arrays',
},
400,
)
}
if (
body.customRole &&
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
) {
return c.json(
{
error: 'Custom role recommendedApps must be an array of strings',
},
400,
)
}
if (
body.customRole &&
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
) {
return c.json(
{
error:
'Custom role boundaries must include key, label, description, and a valid defaultMode',
},
400,
)
}
try {
const agent = await getOpenClawService().createAgent({
name,
roleId: body.roleId,
customRole: body.customRole,
providerType: body.providerType,
providerName: body.providerName,
baseUrl: body.baseUrl,
apiKey: body.apiKey,
modelId: body.modelId,
})
return c.json({ agent }, 201)
} catch (err) {
if (err instanceof OpenClawAgentAlreadyExistsError) {
return c.json({ error: err.message }, 409)
}
if (err instanceof OpenClawInvalidAgentNameError) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.delete('/agents/:id', async (c) => {
const { id } = c.req.param()
try {
await getOpenClawService().removeAgent(id)
return c.json({ success: true })
} catch (err) {
if (err instanceof OpenClawAgentNotFoundError) {
return c.json({ error: err.message }, 404)
}
if (err instanceof OpenClawProtectedAgentError) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/chat', async (c) => {
const { id } = c.req.param()
const body = await c.req.json<{
message: string
sessionKey?: string
}>()
if (!body.message?.trim()) {
return c.json({ error: 'Message is required' }, 400)
}
const sessionKey = body.sessionKey ?? crypto.randomUUID()
try {
const eventStream = await getOpenClawService().chatStream(
id,
sessionKey,
body.message,
)
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('X-Session-Key', sessionKey)
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
)
}
await s.write(encoder.encode('data: [DONE]\n\n'))
} finally {
await reader.cancel()
}
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/logs', async (c) => {
try {
const logs = await getOpenClawService().getLogs()
return c.json({ logs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/providers', async (c) => {
const body = await c.req.json<{
providerType: string
apiKey: string
providerName?: string
baseUrl?: string
modelId?: string
}>()
if (!body.providerType || !body.apiKey) {
return c.json({ error: 'providerType and apiKey are required' }, 400)
}
try {
await getOpenClawService().updateProviderKeys(body)
return c.json({
status: 'restarting',
message: 'Provider updated, restarting gateway',
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
}

View File

@@ -1,94 +0,0 @@
import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/bun'
import { logger } from '../../lib/logger'
import {
parseTerminalClientMessage,
serializeTerminalServerMessage,
} from '../services/terminal/terminal-protocol'
import {
createTerminalSession,
TERMINAL_HOME_DIR,
type TerminalSession,
} from '../services/terminal/terminal-session'
import type { Env } from '../types'
export const TERMINAL_WS_PATH = '/terminal/ws'
interface TerminalRouteDeps {
containerName: string
podmanPath: string
}
function safeSend(ws: { send(data: string): void }, data: string): void {
try {
ws.send(data)
} catch {}
}
function sendOutput(ws: { send(data: string): void }, data: string): void {
safeSend(ws, serializeTerminalServerMessage({ type: 'output', data }))
}
function sendError(ws: { send(data: string): void }, message: string): void {
safeSend(ws, serializeTerminalServerMessage({ type: 'error', message }))
}
function sendExit(ws: { send(data: string): void }, exitCode: number): void {
safeSend(ws, serializeTerminalServerMessage({ type: 'exit', exitCode }))
}
function createSocketEvents(deps: TerminalRouteDeps) {
let session: TerminalSession | null = null
return {
onOpen(_event: Event, ws: { send(data: string): void; close(): void }) {
try {
session = createTerminalSession({
containerName: deps.containerName,
podmanPath: deps.podmanPath,
workingDir: TERMINAL_HOME_DIR,
onOutput(data) {
sendOutput(ws, data)
},
onExit(exitCode) {
sendExit(ws, exitCode)
ws.close()
},
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('Failed to start terminal session', { error: message })
sendError(ws, message)
ws.close()
}
},
onMessage(event: MessageEvent, _ws: { send(data: string): void }) {
const message = parseTerminalClientMessage(event.data)
if (!session || !message) return
if (message.type === 'input') {
session.writeInput(message.data)
} else {
session.resize(message.cols, message.rows)
}
},
onClose() {
session?.close()
session = null
},
onError(_event: Event, ws: { send(data: string): void; close(): void }) {
if (!session) return
session.close()
session = null
sendError(ws, 'Terminal connection error')
ws.close()
},
}
}
export function createTerminalRoutes(deps: TerminalRouteDeps) {
return new Hono<Env>().get(
'/ws',
upgradeWebSocket(() => createSocketEvents(deps)),
)
}

View File

@@ -10,9 +10,7 @@
* - MCP HTTP routes (using @hono/mcp transport)
*/
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import { Hono } from 'hono'
import { websocket } from 'hono/bun'
import { cors } from 'hono/cors'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { HttpAgentError } from '../agent/errors'
@@ -29,7 +27,6 @@ import { createKlavisRoutes } from './routes/klavis'
import { createMcpRoutes } from './routes/mcp'
import { createMemoryRoutes } from './routes/memory'
import { createOAuthRoutes } from './routes/oauth'
import { createOpenClawRoutes } from './routes/openclaw'
import { createProviderRoutes } from './routes/provider'
import { createRefinePromptRoutes } from './routes/refine-prompt'
import { createSdkRoutes } from './routes/sdk'
@@ -37,15 +34,12 @@ import { createShutdownRoute } from './routes/shutdown'
import { createSkillsRoutes } from './routes/skills'
import { createSoulRoutes } from './routes/soul'
import { createStatusRoute } from './routes/status'
import { createTerminalRoutes } from './routes/terminal'
import {
connectKlavisProxy,
type KlavisProxyHandle,
} from './services/klavis/strata-proxy'
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
import type { Env, HttpServerConfig } from './types'
import { defaultCorsConfig } from './utils/cors'
import { requireTrustedAppOrigin } from './utils/request-auth'
async function assertPortAvailable(port: number): Promise<void> {
const net = await import('node:net')
@@ -107,20 +101,6 @@ export async function createHttpServer(config: HttpServerConfig) {
}
}
const clawRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createOpenClawRoutes())
const terminalRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route(
'/',
createTerminalRoutes({
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
podmanPath: getPodmanRuntime().getPodmanPath(),
}),
)
const app = new Hono<Env>()
.use('/*', cors(defaultCorsConfig))
.route('/health', createHealthRoute({ browser }))
@@ -190,7 +170,6 @@ export async function createHttpServer(config: HttpServerConfig) {
browserosId,
}),
)
.route('/claw', clawRoutes)
// Error handler
app.onError((err, c) => {
@@ -232,14 +211,11 @@ export async function createHttpServer(config: HttpServerConfig) {
await assertPortAvailable(port)
app.route('/terminal', terminalRoutes)
const server = Bun.serve({
fetch: (request, server) => app.fetch(request, { server }),
port,
hostname: host,
idleTimeout: 0,
websocket,
})
logger.info('Consolidated HTTP Server started', { port, host })

View File

@@ -64,18 +64,14 @@ export class ChatService {
origin: request.origin,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
toolApprovalConfig: request.toolApprovalConfig,
}
let session = sessionStore.get(request.conversationId)
let isNewSession = false
const contextChanges: string[] = []
// Build stable keys for change detection
// Build a stable key from enabled MCP servers for change detection
const mcpServerKey = this.buildMcpServerKey(request.browserContext)
const approvalConfigKey = this.buildApprovalConfigKey(
request.toolApprovalConfig,
)
// Detect MCP config change mid-conversation → rebuild session
if (session && session.mcpServerKey !== mcpServerKey) {
@@ -148,20 +144,6 @@ export class ChatService {
}
}
// Detect approval config change mid-conversation → rebuild session
if (session && session.approvalConfigKey !== approvalConfigKey) {
logger.info(
'Approval config changed mid-conversation, rebuilding session',
{ conversationId: request.conversationId },
)
session = await this.rebuildSession(
session,
request,
agentConfig,
mcpServerKey,
)
}
if (!session) {
isNewSession = true
let hiddenPageId: number | undefined
@@ -220,7 +202,6 @@ export class ChatService {
klavisClient: this.deps.klavisClient,
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
aclRules: request.aclRules,
})
session = {
agent,
@@ -228,13 +209,10 @@ export class ChatService {
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
approvalConfigKey,
}
sessionStore.set(request.conversationId, session)
}
session.agent.updateAclRules(request.aclRules)
if (isNewSession && request.previousConversation?.length) {
for (const msg of request.previousConversation) {
if (!msg.content.trim()) continue
@@ -250,26 +228,6 @@ export class ChatService {
})
}
// Handle tool approval responses: patch the agent's messages and re-run
if (request.toolApprovalResponses?.length) {
this.applyToolApprovalResponses(
session.agent.messages,
request.toolApprovalResponses,
)
logger.info('Applied tool approval responses', {
conversationId: request.conversationId,
count: request.toolApprovalResponses.length,
})
return createAgentUIStreamResponse({
agent: session.agent.toolLoopAgent,
uiMessages: filterValidMessages(session.agent.messages),
abortSignal,
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
session.agent.messages = filterValidMessages(messages)
},
})
}
const messageContext = request.isScheduledTask
? (session.browserContext ?? request.browserContext)
: request.browserContext
@@ -400,7 +358,6 @@ export class ChatService {
klavisClient: this.deps.klavisClient,
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
aclRules: request.aclRules,
})
const newSession: AgentSession = {
agent,
@@ -408,9 +365,6 @@ export class ChatService {
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
approvalConfigKey: this.buildApprovalConfigKey(
request.toolApprovalConfig,
),
}
newSession.agent.messages = sanitizeMessagesForToolset(
previousMessages,
@@ -420,51 +374,6 @@ export class ChatService {
return newSession
}
private applyToolApprovalResponses(
messages: UIMessage[],
responses: Array<{
approvalId: string
approved: boolean
reason?: string
}>,
): void {
const responseMap = new Map(responses.map((r) => [r.approvalId, r]))
for (const msg of messages) {
if (msg.role !== 'assistant') continue
for (const part of msg.parts) {
const toolPart = part as {
state?: string
approval?: { id: string; approved?: boolean; reason?: string }
}
if (
toolPart.state === 'approval-requested' &&
toolPart.approval?.id &&
responseMap.has(toolPart.approval.id)
) {
const resp = responseMap.get(toolPart.approval.id)
if (!resp) continue
toolPart.state = 'approval-responded'
toolPart.approval = {
...toolPart.approval,
approved: resp.approved,
reason: resp.reason,
}
}
}
}
}
private buildApprovalConfigKey(config?: {
categories: Record<string, boolean>
}): string {
if (!config) return ''
return Object.entries(config.categories)
.filter(([, v]) => v)
.map(([k]) => k)
.sort()
.join(',')
}
private buildMcpServerKey(browserContext?: BrowserContext): string {
const managed = browserContext?.enabledMcpServers?.slice().sort() ?? []
const custom =

View File

@@ -9,9 +9,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import { jsonSchemaObjectToZodRawShape } from 'zod-from-json-schema'
import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
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'
import { metrics } from '../../../lib/metrics'
@@ -29,7 +28,6 @@ function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
}
export interface KlavisProxyHandle {
browserosId: string
tools: Tool[]
inputSchemas: Map<string, Record<string, never>>
callTool: (
@@ -87,7 +85,6 @@ export async function connectKlavisProxy(
})
return {
browserosId: deps.browserosId,
tools,
inputSchemas,
callTool: (name, args) =>
@@ -96,121 +93,10 @@ export async function connectKlavisProxy(
}
}
const serverNames = OAUTH_MCP_SERVERS.map((s) => s.name) as [
string,
...string[],
]
const serverDescriptions = OAUTH_MCP_SERVERS.map(
(s) => `${s.name} (${s.description})`,
).join(', ')
// Double cast works around TS2589 in registerTool's recursive generics.
const connectorInputSchema = {
server_name: z
.enum(serverNames)
.describe(
`The name of the service to check. Available: ${serverDescriptions}`,
),
} as unknown as Record<string, never>
export function registerKlavisTools(
mcpServer: McpServer,
handle: KlavisProxyHandle,
): void {
// Register the connector discovery tool
mcpServer.registerTool(
'connector_mcp_servers',
{
description:
'Check if an external service is connected and ready for use with Strata MCP tools (discover_server_categories_or_actions, execute_action, etc.). Call this BEFORE using any Strata integration tool. If connected, proceed with Strata tools. If not connected, returns an authUrl — prompt the user to open it and authenticate.',
inputSchema: connectorInputSchema,
},
async (args: Record<string, unknown>) => {
const startTime = performance.now()
const server_name = args.server_name as string
try {
const klavisClient = new KlavisClient()
const integrations = await klavisClient.getUserIntegrations(
handle.browserosId,
)
const integration = integrations.find((i) => i.name === server_name)
const isConnected = integration?.isAuthenticated === true
if (isConnected) {
metrics.log('tool_executed', {
tool_name: 'connector_mcp_servers',
source: 'mcp',
duration_ms: Math.round(performance.now() - startTime),
success: true,
})
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
connected: true,
server_name,
}),
},
],
}
}
// Not connected — get auth URL
const strata = await klavisClient.createStrata(handle.browserosId, [
server_name,
])
const authUrl =
strata.oauthUrls?.[server_name] ??
strata.apiKeyUrls?.[server_name] ??
null
metrics.log('tool_executed', {
tool_name: 'connector_mcp_servers',
source: 'mcp',
duration_ms: Math.round(performance.now() - startTime),
success: true,
})
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
connected: false,
server_name,
authUrl,
message: authUrl
? `${server_name} is not connected. Ask the user to open this URL to authenticate: ${authUrl}`
: `${server_name} is not connected. Could not retrieve auth URL.`,
}),
},
],
}
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error)
metrics.log('tool_executed', {
tool_name: 'connector_mcp_servers',
source: 'mcp',
duration_ms: Math.round(performance.now() - startTime),
success: false,
error_message: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,
}
}
},
)
// Register Strata proxy tools
for (const tool of handle.tools) {
const inputSchema = handle.inputSchemas.get(tool.name)
@@ -255,6 +141,6 @@ export function registerKlavisTools(
}
logger.debug('Registered Klavis tools on MCP server', {
count: handle.tools.length + 1,
count: handle.tools.length,
})
}

View File

@@ -27,21 +27,16 @@ Error recovery:
40+ services: Gmail, Slack, GitHub, Notion, Google Calendar, Jira, Linear, Figma, Salesforce, and more.
Before using any Strata integration, call connector_mcp_servers(server_name) to verify the service is connected.
- If connected → proceed with Strata discovery tools below.
- If not connected → prompt the user with the returned authUrl to authenticate. After they confirm, call connector_mcp_servers again to verify.
Progressive discovery — do not guess action names:
1. connector_mcp_servers → check connection status first.
2. discover_server_categories_or_actions → discover available actions.
3. get_category_actions → expand categories from step 2.
4. get_action_details → get parameter schema before executing.
5. execute_action → use include_output_fields to limit response size.
6. search_documentation → fallback keyword search.
1. discover_server_categories_or_actions → always start here.
2. get_category_actions → expand categories from step 1.
3. get_action_details → get parameter schema before executing.
4. execute_action → use include_output_fields to limit response size.
5. search_documentation → fallback keyword search.
Authentication — when execute_action returns an auth error:
1. Call connector_mcp_servers(server_name) to get a fresh authUrl.
2. Prompt the user to open the authUrl and authenticate.
1. handle_auth_failure(server_name, intention: "get_auth_url").
2. new_page(auth_url) to open in browser for user to authenticate.
3. Wait for explicit user confirmation before retrying.
## General

View File

@@ -1,166 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Compose-level abstraction over PodmanRuntime.
* Manages a single compose project for the OpenClaw gateway container.
*/
import { copyFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
OPENCLAW_COMPOSE_PROJECT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
} from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
import type { LogFn, PodmanRuntime } from './podman-runtime'
const COMPOSE_FILE_NAME = 'docker-compose.yml'
const ENV_FILE_NAME = '.env'
export class ContainerRuntime {
constructor(
private podman: PodmanRuntime,
private projectDir: string,
) {}
async ensureReady(onLog?: LogFn): Promise<void> {
return this.podman.ensureReady(onLog)
}
async isPodmanAvailable(): Promise<boolean> {
return this.podman.isPodmanAvailable()
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return this.podman.getMachineStatus()
}
async composeUp(onLog?: LogFn): Promise<void> {
const code = await this.compose(['up', '-d'], onLog)
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
}
async composeDown(onLog?: LogFn): Promise<void> {
const code = await this.compose(['down'], onLog)
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
}
async composeStop(onLog?: LogFn): Promise<void> {
const code = await this.compose(['stop'], onLog)
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
}
async composeRestart(onLog?: LogFn): Promise<void> {
const code = await this.compose(['restart'], onLog)
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
}
async composePull(onLog?: LogFn): Promise<void> {
const code = await this.compose(['pull', '--quiet'], onLog)
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
}
async composeLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
lines.push(line),
)
return lines
}
async isHealthy(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(port)) return true
await Bun.sleep(1000)
}
return false
}
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
}
async writeEnvFile(content: string): Promise<void> {
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
mode: 0o600,
})
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* Prevents killing the user's own Podman workloads.
*/
async stopMachineIfSafe(): Promise<void> {
const status = await this.podman.getMachineStatus()
if (!status.running) return
try {
const containers = await this.podman.listRunningContainers()
const allOurs = containers.every((name) =>
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
)
if (containers.length === 0 || allOurs) {
await this.podman.stopMachine()
}
} catch {
// Best effort — don't stop machine if we can't check
}
}
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.podman.runCommand(
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
{
onOutput: onLog,
},
)
}
private async compose(args: string[], onLog?: LogFn): Promise<number> {
const lines: string[] = []
const code = await this.podman.runCommand(['compose', ...args], {
cwd: this.projectDir,
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
onOutput: (line) => {
lines.push(line)
onLog?.(line)
},
})
if (code !== 0) {
logger.error('OpenClaw compose command failed', {
command: ['podman', 'compose', ...args].join(' '),
projectDir: this.projectDir,
exitCode: code,
output: lines,
})
}
return code
}
}

View File

@@ -1,29 +0,0 @@
export class OpenClawInvalidAgentNameError extends Error {
constructor() {
super(
'Agent name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens',
)
this.name = 'OpenClawInvalidAgentNameError'
}
}
export class OpenClawAgentAlreadyExistsError extends Error {
constructor(agentId: string) {
super(`Agent "${agentId}" already exists`)
this.name = 'OpenClawAgentAlreadyExistsError'
}
}
export class OpenClawAgentNotFoundError extends Error {
constructor(agentId: string) {
super(`Agent "${agentId}" not found`)
this.name = 'OpenClawAgentNotFoundError'
}
}
export class OpenClawProtectedAgentError extends Error {
constructor(message: string) {
super(message)
this.name = 'OpenClawProtectedAgentError'
}
}

View File

@@ -1,744 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* WebSocket client for the OpenClaw Gateway protocol.
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
* identity signing, JSON-RPC over WS, and auto-reconnect.
* Used for agent CRUD and health — chat uses HTTP.
*/
import crypto from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
const RPC_TIMEOUT_MS = 15_000
const SCOPES = [
'operator.read',
'operator.write',
'operator.admin',
'operator.approvals',
'operator.pairing',
]
interface DeviceIdentity {
deviceId: string
publicKeyPem: string
privateKeyPem: string
}
interface PendingRequest {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
interface WsFrame {
type: 'req' | 'res' | 'event'
id?: string
method?: string
params?: Record<string, unknown>
ok?: boolean
payload?: Record<string, unknown>
error?: { message: string; code?: string }
event?: string
}
export type GatewayClientConnectionState =
| 'idle'
| 'connecting'
| 'connected'
| 'closed'
| 'failed'
export interface GatewayHandshakeError {
code?: string
message: string
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export interface GatewayAgentEntry {
agentId: string
name: string
workspace: string
model?: string
}
// ── Device Identity Helpers ─────────────────────────────────────────
function rawPublicKeyFromPem(pem: string): Buffer {
const der = Buffer.from(
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
'base64',
)
return der.subarray(12)
}
function signChallenge(
device: DeviceIdentity,
nonce: string,
token: string,
): { signature: string; signedAt: number; publicKey: string } {
const signedAt = Date.now()
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
return {
signature: sig.toString('base64url'),
signedAt,
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
}
}
/**
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
* paired devices file so the gateway trusts it on next boot.
* Must be called before compose up (or requires a restart after).
*/
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
const identityPath = join(openclawDir, 'client-identity.json')
try {
return JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
// Generate new identity
}
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
const publicKeyPem = publicKey
.export({ type: 'spki', format: 'pem' })
.toString()
const privateKeyPem = privateKey
.export({ type: 'pkcs8', format: 'pem' })
.toString()
const rawPub = rawPublicKeyFromPem(publicKeyPem)
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
mode: 0o600,
})
seedPairedDevice(openclawDir, identity)
logger.info('Generated client device identity and pre-seeded pairing')
return identity
}
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
const devicesDir = join(openclawDir, 'devices')
mkdirSync(devicesDir, { recursive: true })
const pairedPath = join(devicesDir, 'paired.json')
let paired: Record<string, unknown> = {}
try {
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
} catch {
// First time
}
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
paired[identity.deviceId] = {
deviceId: identity.deviceId,
publicKey: rawPub.toString('base64url'),
platform: process.platform,
clientId: 'cli',
clientMode: 'cli',
role: 'operator',
roles: ['operator'],
scopes: SCOPES,
pairedAt: Date.now(),
label: 'browseros-server',
}
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
}
// ── Gateway Client ──────────────────────────────────────────────────
export class GatewayClient {
private ws: WebSocket | null = null
private _connected = false
private pendingRequests = new Map<string, PendingRequest>()
private device: DeviceIdentity | null = null
private connectionState: GatewayClientConnectionState = 'idle'
private lastHandshakeError: GatewayHandshakeError | null = null
constructor(
private readonly port: number,
private readonly token: string,
private readonly openclawDir: string,
private readonly version = '1.0.0',
) {
try {
const identityPath = join(this.openclawDir, 'client-identity.json')
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
logger.warn('Client device identity not found, WS auth may fail')
}
}
get isConnected(): boolean {
return this._connected
}
get state(): GatewayClientConnectionState {
return this.connectionState
}
get lastError(): GatewayHandshakeError | null {
return this.lastHandshakeError
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.connectionState = 'connecting'
this.lastHandshakeError = null
const url = `ws://127.0.0.1:${this.port}`
this.ws = new WebSocket(url, {
headers: { Origin: `http://127.0.0.1:${this.port}` },
} as unknown as string[])
let handshakeComplete = false
let connectReqId: string | null = null
this.ws.onmessage = (event) => {
const frame = GatewayClient.parseFrame(event.data)
if (!frame) return
if (!handshakeComplete) {
if (frame.type === 'event' && frame.event === 'connect.challenge') {
const nonce = (frame.payload as Record<string, unknown>)
?.nonce as string
connectReqId = globalThis.crypto.randomUUID()
const params: Record<string, unknown> = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'cli',
version: this.version,
platform: process.platform,
mode: 'cli',
},
role: 'operator',
scopes: SCOPES,
caps: [],
commands: [],
permissions: {},
auth: { token: this.token },
locale: 'en-US',
userAgent: `browseros-server/${this.version}`,
}
if (this.device && nonce) {
const signed = signChallenge(this.device, nonce, this.token)
params.device = {
id: this.device.deviceId,
publicKey: signed.publicKey,
signature: signed.signature,
signedAt: signed.signedAt,
nonce,
}
}
this.ws?.send(
JSON.stringify({
type: 'req',
id: connectReqId,
method: 'connect',
params,
}),
)
return
}
if (frame.type === 'res' && frame.id === connectReqId) {
if (frame.ok) {
handshakeComplete = true
this._connected = true
this.connectionState = 'connected'
logger.info('Gateway WS connected')
resolve()
} else {
const msg = frame.error?.message ?? 'Handshake failed'
this.connectionState = 'failed'
this.lastHandshakeError = {
message: msg,
code: frame.error?.code,
}
logger.error('Gateway WS handshake rejected', {
error: msg,
code: frame.error?.code,
})
reject(new Error(msg))
}
return
}
return
}
this.resolvePendingRequest(frame)
}
this.ws.onerror = (err) => {
if (!handshakeComplete) {
this.connectionState = 'failed'
reject(
new Error(
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
),
)
}
}
this.ws.onclose = () => {
this._connected = false
this.connectionState = 'closed'
this.rejectAllPending('WebSocket closed')
if (handshakeComplete) {
logger.info('Gateway WS disconnected')
}
this.ws = null
}
})
}
disconnect(): void {
this._connected = false
this.connectionState = 'closed'
this.rejectAllPending('Client disconnecting')
if (this.ws) {
this.ws.onclose = null
this.ws.close()
this.ws = null
}
}
// ── RPC ──────────────────────────────────────────────────────────────
async rpc<T = Record<string, unknown>>(
method: string,
params: Record<string, unknown> = {},
): Promise<T> {
if (!this._connected || !this.ws) {
throw new Error('Gateway WS not connected')
}
const id = globalThis.crypto.randomUUID()
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new Error(`RPC timeout: ${method}`))
}, RPC_TIMEOUT_MS)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer,
})
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
})
}
// ── Agent Methods ────────────────────────────────────────────────────
async listAgents(): Promise<GatewayAgentEntry[]> {
const result = await this.rpc<{
agents: Array<{
id: string
name?: string
workspace: string
model?: string
}>
}>('agents.list')
return (result.agents ?? []).map((a) => ({
agentId: a.id,
name: a.name ?? a.id,
workspace: a.workspace,
model: a.model,
}))
}
async createAgent(input: {
name: string
workspace: string
model?: string
}): Promise<GatewayAgentEntry> {
const result = await this.rpc<{
agentId?: string
id?: string
name?: string
workspace?: string
model?: string
}>('agents.create', input)
return {
agentId: result.agentId ?? result.id ?? input.name,
name: result.name ?? input.name,
workspace: result.workspace ?? input.workspace,
model: result.model ?? input.model,
}
}
async deleteAgent(agentId: string): Promise<void> {
await this.rpc('agents.delete', { id: agentId })
}
// ── Health ───────────────────────────────────────────────────────────
async getHealth(): Promise<Record<string, unknown>> {
return this.rpc('health')
}
// ── Chat Stream ─────────────────────────────────────────────────────
chatStream(
agentId: string,
sessionKey: string,
message: string,
): ReadableStream<OpenClawStreamEvent> {
if (!this._connected) {
throw new Error('Gateway WS not connected')
}
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
const idempotencyKey = globalThis.crypto.randomUUID()
const streamClient = new GatewayClient(
this.port,
this.token,
this.openclawDir,
this.version,
)
return new ReadableStream<OpenClawStreamEvent>({
start: async (controller) => {
try {
await streamClient.connect()
} catch (error) {
controller.enqueue({
type: 'error',
data: {
message:
error instanceof Error
? error.message
: 'Gateway WS not connected',
},
})
controller.close()
return
}
const ws = streamClient.ws
if (!ws) {
controller.enqueue({
type: 'error',
data: { message: 'Gateway WS not connected' },
})
controller.close()
return
}
const subscribeId = globalThis.crypto.randomUUID()
const agentReqId = globalThis.crypto.randomUUID()
let finished = false
const finish = (event?: OpenClawStreamEvent) => {
if (finished) return
finished = true
if (event) controller.enqueue(event)
controller.close()
streamClient.disconnect()
}
ws.onmessage = (event) => {
const frame = GatewayClient.parseFrame(event.data)
if (!frame) return
if (
this.handleChatStreamControlFrame(
frame,
subscribeId,
agentReqId,
finish,
)
) {
return
}
this.handleChatStreamEventFrame(frame, controller, finish)
}
ws.onclose = () => {
if (finished) return
finish({
type: 'error',
data: { message: 'Gateway WS disconnected' },
})
}
ws.onerror = () => {
if (finished) return
finish({
type: 'error',
data: { message: 'Gateway WS connection error' },
})
}
ws.send(
JSON.stringify({
type: 'req',
id: subscribeId,
method: 'sessions.subscribe',
params: { sessionKey: fullSessionKey },
}),
)
ws.send(
JSON.stringify({
type: 'req',
id: agentReqId,
method: 'agent',
params: {
message,
sessionKey: fullSessionKey,
idempotencyKey,
},
}),
)
},
cancel: () => {
if (streamClient.ws?.readyState === WebSocket.OPEN) {
streamClient.ws.send(
JSON.stringify({
type: 'req',
id: globalThis.crypto.randomUUID(),
method: 'sessions.abort',
params: { sessionKey: fullSessionKey },
}),
)
}
streamClient.disconnect()
},
})
}
// ── Helpers ──────────────────────────────────────────────────────────
static agentWorkspace(name: string): string {
return name === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
}
private static parseFrame(data: unknown): WsFrame | null {
try {
return JSON.parse(
typeof data === 'string'
? data
: new TextDecoder().decode(data as ArrayBuffer),
) as WsFrame
} catch {
return null
}
}
private rejectAllPending(reason: string): void {
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
this.pendingRequests.delete(id)
}
}
private resolvePendingRequest(frame: WsFrame): void {
if (frame.type !== 'res' || !frame.id) return
const pending = this.pendingRequests.get(frame.id)
if (!pending) return
this.pendingRequests.delete(frame.id)
clearTimeout(pending.timer)
if (frame.ok) {
pending.resolve(frame.payload)
} else {
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
}
}
private handleChatStreamControlFrame(
frame: WsFrame,
subscribeId: string,
agentReqId: string,
finish: (event?: OpenClawStreamEvent) => void,
): boolean {
if (frame.type !== 'res' || !frame.id) return false
if (frame.id !== subscribeId && frame.id !== agentReqId) return false
if (!frame.ok) {
finish({
type: 'error',
data: {
message: frame.error?.message ?? 'RPC error',
code: frame.error?.code,
},
})
}
return true
}
private handleChatStreamEventFrame(
frame: WsFrame,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
finish: (event?: OpenClawStreamEvent) => void,
): void {
if (frame.type !== 'event' || !frame.event || !frame.payload) return
switch (frame.event) {
case 'agent':
this.handleAgentStreamEvent(frame.payload, controller)
return
case 'session.tool':
this.handleSessionToolStreamEvent(frame.payload, controller)
return
case 'session.message':
this.handleSessionMessageStreamEvent(frame.payload, controller)
return
case 'chat':
this.handleChatCompletionEvent(frame.payload, finish)
return
default:
return
}
}
private handleAgentStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const streamType = payload.stream as string | undefined
const data = payload.data as Record<string, unknown> | undefined
if (streamType === 'assistant' && data?.delta) {
controller.enqueue({
type: 'text-delta',
data: { text: data.delta },
})
return
}
if (streamType === 'item' && data) {
const phase = data.phase as string | undefined
if (phase === 'start') {
controller.enqueue({
type: 'tool-start',
data: {
toolCallId: data.toolCallId ?? data.id,
toolName: data.name ?? data.title,
kind: data.kind,
},
})
return
}
if (phase === 'end') {
controller.enqueue({
type: 'tool-end',
data: {
toolCallId: data.toolCallId ?? data.id,
status: data.status,
durationMs: data.durationMs,
},
})
return
}
}
if (streamType === 'lifecycle') {
controller.enqueue({
type: 'lifecycle',
data: { phase: data?.phase ?? payload.phase },
})
}
}
private handleSessionToolStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const toolData = (payload.data as Record<string, unknown>) ?? payload
const phase = (toolData.phase as string) ?? (payload.phase as string)
if (phase !== 'result') return
controller.enqueue({
type: 'tool-output',
data: {
toolCallId: toolData.toolCallId,
isError: toolData.isError ?? false,
meta: toolData.meta,
},
})
}
private handleSessionMessageStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const message = payload.message as Record<string, unknown> | undefined
if (message?.role !== 'assistant') return
const content = message.content as
| Array<Record<string, unknown>>
| undefined
if (!content) return
for (const block of content) {
if (block.type !== 'thinking') continue
const text =
(block.thinking as string) ??
(block.content as string) ??
(block.text as string) ??
''
if (!text) continue
controller.enqueue({
type: 'thinking',
data: { text },
})
}
}
private handleChatCompletionEvent(
payload: Record<string, unknown>,
finish: (event?: OpenClawStreamEvent) => void,
): void {
if ((payload.state as string | undefined) !== 'final') return
finish({
type: 'done',
data: { text: (payload.text as string) ?? '' },
})
}
}

View File

@@ -1,256 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Pure functions for building OpenClaw bootstrap configuration.
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
*/
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_PORT,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
export const PROVIDER_ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
groq: 'GROQ_API_KEY',
mistral: 'MISTRAL_API_KEY',
}
export interface OpenClawProviderInput {
providerType?: string
providerName?: string
baseUrl?: string
modelId?: string
apiKey?: string
}
export interface BootstrapConfigInput {
gatewayPort: number
gatewayToken: string
browserosServerPort?: number
providerType?: string
providerName?: string
baseUrl?: string
modelId?: string
}
export interface EnvFileInput {
image?: string
port?: number
token: string
configDir: string
timezone?: string
providerKeys?: Record<string, string>
}
export interface ResolvedProviderConfig {
model?: string
providerKeys: Record<string, string>
models?: {
mode: 'merge'
providers: Record<string, Record<string, unknown>>
}
}
function hasBuiltinProvider(providerType?: string): providerType is string {
return !!providerType && providerType in PROVIDER_ENV_MAP
}
export function deriveOpenClawProviderId(providerInput: {
providerType?: string
providerName?: string
baseUrl?: string
}): string {
const source =
providerInput.providerName?.trim() ||
providerInput.baseUrl?.trim() ||
providerInput.providerType?.trim() ||
'custom-provider'
const candidate = source
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return candidate || 'custom-provider'
}
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
}
export function resolveProviderConfig(
input: OpenClawProviderInput,
): ResolvedProviderConfig {
if (!input.providerType) {
return { providerKeys: {} }
}
if (hasBuiltinProvider(input.providerType)) {
const providerKeys: Record<string, string> = {}
if (input.apiKey) {
providerKeys[PROVIDER_ENV_MAP[input.providerType]] = input.apiKey
}
return {
providerKeys,
model: input.modelId
? `${input.providerType}/${input.modelId}`
: undefined,
}
}
if (!input.baseUrl) {
return { providerKeys: {} }
}
const providerId = deriveOpenClawProviderId(input)
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
const providerKeys: Record<string, string> = {}
if (input.apiKey) {
providerKeys[apiKeyEnvVar] = input.apiKey
}
const providerConfig: Record<string, unknown> = {
baseUrl: input.baseUrl,
apiKey: `\${${apiKeyEnvVar}}`,
api: 'openai-completions',
}
if (input.modelId) {
providerConfig.models = [{ id: input.modelId, name: input.modelId }]
}
return {
providerKeys,
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
models: {
mode: 'merge',
providers: {
[providerId]: providerConfig,
},
},
}
}
export function buildBootstrapConfig(
input: BootstrapConfigInput,
): Record<string, unknown> {
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
const provider = resolveProviderConfig(input)
const defaults: Record<string, unknown> = {
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
timeoutSeconds: 4200,
thinkingDefault: 'adaptive',
}
if (provider.model) {
defaults.model = { primary: provider.model }
}
const config: Record<string, unknown> = {
gateway: {
mode: 'local',
port: input.gatewayPort,
bind: 'lan',
auth: { mode: 'token', token: input.gatewayToken },
reload: { mode: 'restart' },
controlUi: {
allowInsecureAuth: true,
allowedOrigins: [
`http://127.0.0.1:${input.gatewayPort}`,
`http://localhost:${input.gatewayPort}`,
],
},
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: { defaults },
tools: {
profile: 'full',
web: {
search: { provider: 'duckduckgo', enabled: true },
},
exec: {
host: 'gateway',
security: 'full',
ask: 'off',
},
},
cron: { enabled: true },
hooks: {
internal: {
enabled: true,
entries: {
'boot-md': { enabled: true },
'bootstrap-extra-files': { enabled: true },
'session-memory': { enabled: true },
},
},
},
mcp: {
servers: {
browseros: {
url: `http://host.containers.internal:${serverPort}/mcp`,
transport: 'streamable-http',
},
},
},
approvals: {
exec: { enabled: false },
},
skills: {
install: { nodeManager: 'bun' },
},
}
if (provider.models) {
config.models = provider.models
}
return config
}
export function buildEnvFile(input: EnvFileInput): string {
const lines: string[] = [
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
]
if (input.providerKeys) {
for (const [key, value] of Object.entries(input.providerKeys)) {
lines.push(`${key}=${value}`)
}
}
return `${lines.join('\n')}\n`
}
export function resolveProviderKeys(
input: OpenClawProviderInput,
): Record<string, string> {
return resolveProviderConfig(input).providerKeys
}
export function resolveProviderModel(
input: OpenClawProviderInput,
): string | undefined {
return resolveProviderConfig(input).model
}

View File

@@ -1,223 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Abstraction over the Podman CLI for container lifecycle management.
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
* On Linux, machine operations are no-ops since Podman runs natively.
*/
const isLinux = process.platform === 'linux'
export type LogFn = (msg: string) => void
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
constructor(config?: { podmanPath?: string }) {
this.podmanPath = config?.podmanPath ?? 'podman'
}
getPodmanPath(): string {
return this.podmanPath
}
async isPodmanAvailable(): Promise<boolean> {
try {
const proc = Bun.spawn([this.podmanPath, '--version'], {
stdout: 'ignore',
stderr: 'ignore',
})
return (await proc.exited) === 0
} catch {
return false
}
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
if (isLinux) return { initialized: true, running: true }
try {
const proc = Bun.spawn(
[this.podmanPath, 'machine', 'list', '--format', 'json'],
{ stdout: 'pipe', stderr: 'ignore' },
)
const output = await new Response(proc.stdout).text()
await proc.exited
const machines = JSON.parse(output) as Array<{
Running?: boolean
LastUp?: string
}>
if (!machines.length) return { initialized: false, running: false }
const machine = machines[0]
const running =
machine.Running === true || machine.LastUp === 'Currently running'
return { initialized: true, running }
} catch {
return { initialized: false, running: false }
}
}
async initMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn(
[
this.podmanPath,
'machine',
'init',
'--cpus',
'2',
'--memory',
'2048',
'--disk-size',
'10',
],
{ stdout: 'ignore', stderr: 'pipe' },
)
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine init failed with code ${code}`)
}
async startMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
stdout: 'ignore',
stderr: 'pipe',
})
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine start failed with code ${code}`)
}
async stopMachine(): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
stdout: 'ignore',
stderr: 'ignore',
})
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine stop failed with code ${code}`)
this.machineReady = false
}
async ensureReady(onLog?: LogFn): Promise<void> {
if (this.machineReady) return
const status = await this.getMachineStatus()
if (!status.initialized) {
onLog?.('Initializing Podman machine...')
await this.initMachine(onLog)
}
if (!status.running) {
onLog?.('Starting Podman machine...')
await this.startMachine(onLog)
}
this.machineReady = true
}
async runCommand(
args: string[],
options?: {
cwd?: string
env?: Record<string, string>
onOutput?: (line: string) => void
},
): Promise<number> {
const useStreaming = !!options?.onOutput
const proc = Bun.spawn([this.podmanPath, ...args], {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
stdout: useStreaming ? 'pipe' : 'ignore',
stderr: useStreaming ? 'pipe' : 'ignore',
})
if (options?.onOutput) {
await Promise.all([
this.drainStream(proc.stdout ?? null, options.onOutput),
this.drainStream(proc.stderr ?? null, options.onOutput),
])
}
return proc.exited
}
/**
* Lists running container names. Used to check whether non-BrowserOS
* containers are running before stopping the Podman machine.
*/
async listRunningContainers(): Promise<string[]> {
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
stdout: 'pipe',
stderr: 'ignore',
})
const output = await new Response(proc.stdout).text()
await proc.exited
return output
.trim()
.split('\n')
.filter((name) => name.trim())
}
private async drainStderr(
proc: {
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
},
onLog?: LogFn,
): Promise<void> {
if (!onLog || !proc.stderr) return
await this.drainStream(proc.stderr, onLog)
}
private async drainStream(
stream: ReadableStream<Uint8Array> | null,
onLine: (line: string) => void,
): Promise<void> {
if (!stream) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
}
if (buffer.trim()) onLine(buffer.trim())
}
}
let runtime: PodmanRuntime | null = null
export function getPodmanRuntime(): PodmanRuntime {
if (!runtime) runtime = new PodmanRuntime()
return runtime
}

View File

@@ -1,116 +0,0 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { BrowserOSAgentProgram } from '@browseros/shared/types/role-programs'
import type { OpenClawProgramStorage } from './program-storage'
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'manual only'
case 'daily': {
const weekdaySummary = program.schedule.daysOfWeek?.length
? ` on ${program.schedule.daysOfWeek.join(', ')}`
: ''
return `daily at ${program.schedule.time}${weekdaySummary}`
}
case 'hourly':
return `every ${program.schedule.interval} hour(s)`
case 'minutes':
return `every ${program.schedule.interval} minute(s)`
}
}
function buildProgramsMd(programs: BrowserOSAgentProgram[]): string {
const sections =
programs.length === 0
? ['No BrowserOS-managed programs configured yet.']
: programs.map(
(program) => `## ${program.name}
- Status: ${program.enabled ? 'enabled' : 'disabled'}
- Schedule: ${describeSchedule(program)}
- Goal: ${program.description}
- Prompt: ${program.prompt}
`,
)
return `# BrowserOS Programs
This file is generated by BrowserOS. Edit program settings in BrowserOS, not here.
${sections.join('\n')}
`
}
function buildStandingOrdersMd(programs: BrowserOSAgentProgram[]): string {
const sections = programs.flatMap((program) => {
if (program.standingOrders.length === 0) return []
const lines = program.standingOrders
.map(
(order) =>
`- ${order.title} (${order.enabled ? 'enabled' : 'disabled'}): ${order.instruction}`,
)
.join('\n')
return [`## ${program.name}\n${lines}`]
})
return `# Standing Orders
This file is generated by BrowserOS. Edit standing orders in BrowserOS, not here.
${sections.length > 0 ? sections.join('\n\n') : 'No standing orders configured yet.'}
`
}
function buildProgramsMetadata(
agentName: string,
programs: BrowserOSAgentProgram[],
): string {
return `${JSON.stringify(
{
version: 1,
agentName,
programs: programs.map((program) => ({
id: program.id,
name: program.name,
enabled: program.enabled,
schedule: program.schedule,
updatedAt: program.updatedAt,
})),
},
null,
2,
)}\n`
}
export class OpenClawProgramMaterializer {
constructor(
private openclawDir: string,
private storage: OpenClawProgramStorage,
) {}
private getHostWorkspaceDir(agentName: string): string {
return join(
this.openclawDir,
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
}
async syncAgentPrograms(agentName: string): Promise<void> {
const programs = await this.storage.listPrograms(agentName)
const workspaceDir = this.getHostWorkspaceDir(agentName)
await mkdir(workspaceDir, { recursive: true })
await Promise.all([
writeFile(join(workspaceDir, 'PROGRAMS.md'), buildProgramsMd(programs)),
writeFile(
join(workspaceDir, 'STANDING-ORDERS.md'),
buildStandingOrdersMd(programs),
),
writeFile(
join(workspaceDir, '.browseros-programs.json'),
buildProgramsMetadata(agentName, programs),
),
])
}
}

View File

@@ -1,140 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
interface ProgramStorageAgent {
agentId: string
name: string
role?: {
roleId?: string
}
}
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const content = await readFile(filePath, 'utf-8')
return JSON.parse(content) as T
} catch {
return fallback
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
function sortPrograms(programs: BrowserOSAgentProgram[]) {
return [...programs].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
)
}
export class OpenClawProgramStorage {
constructor(private openclawDir: string) {}
private getProgramsFile(agentName: string): string {
return join(this.openclawDir, 'programs', `${agentName}.json`)
}
private getProgramRunsFile(agentName: string): string {
return join(this.openclawDir, 'program-runs', `${agentName}.json`)
}
async listPrograms(agentName: string): Promise<BrowserOSAgentProgram[]> {
const programs = await readJsonFile<BrowserOSAgentProgram[]>(
this.getProgramsFile(agentName),
[],
)
return sortPrograms(programs)
}
async getProgram(
agentName: string,
programId: string,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
return programs.find((program) => program.id === programId) ?? null
}
async createProgram(
agent: ProgramStorageAgent,
input: CreateAgentProgramInput,
): Promise<BrowserOSAgentProgram> {
const programs = await this.listPrograms(agent.name)
const now = new Date().toISOString()
const program: BrowserOSAgentProgram = {
id: crypto.randomUUID(),
agentId: agent.agentId,
agentName: agent.name,
roleId: agent.role?.roleId,
name: input.name,
description: input.description,
prompt: input.prompt,
schedule: input.schedule,
enabled: input.enabled ?? true,
standingOrders: input.standingOrders ?? [],
createdAt: now,
updatedAt: now,
}
await writeJsonFile(this.getProgramsFile(agent.name), [
...programs,
program,
])
return program
}
async updateProgram(
agentName: string,
programId: string,
input: UpdateAgentProgramInput,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
const current = programs.find((program) => program.id === programId)
if (!current) return null
const nextProgram: BrowserOSAgentProgram = {
...current,
...input,
updatedAt: new Date().toISOString(),
}
await writeJsonFile(
this.getProgramsFile(agentName),
programs.map((program) =>
program.id === programId ? nextProgram : program,
),
)
return nextProgram
}
async deleteProgram(agentName: string, programId: string): Promise<boolean> {
const programs = await this.listPrograms(agentName)
const remaining = programs.filter((program) => program.id !== programId)
if (remaining.length === programs.length) return false
await writeJsonFile(this.getProgramsFile(agentName), remaining)
return true
}
async listRuns(agentName: string): Promise<BrowserOSProgramRun[]> {
return readJsonFile<BrowserOSProgramRun[]>(
this.getProgramRunsFile(agentName),
[],
)
}
async writeRuns(
agentName: string,
runs: BrowserOSProgramRun[],
): Promise<void> {
await writeJsonFile(this.getProgramRunsFile(agentName), runs)
}
}

View File

@@ -1,179 +0,0 @@
import type {
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function assertNonEmptyString(
value: unknown,
field: string,
): asserts value is string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} is required`)
}
}
function validateStandingOrder(value: unknown): BrowserOSStandingOrder {
if (!isRecord(value)) {
throw new Error('Standing orders must be objects')
}
assertNonEmptyString(value.title, 'Standing order title')
assertNonEmptyString(value.instruction, 'Standing order instruction')
if (typeof value.enabled !== 'boolean') {
throw new Error('Standing order enabled must be a boolean')
}
return {
id:
typeof value.id === 'string' && value.id.trim() !== ''
? value.id
: crypto.randomUUID(),
title: value.title.trim(),
instruction: value.instruction.trim(),
enabled: value.enabled,
}
}
function validateStandingOrders(
value: unknown,
): BrowserOSStandingOrder[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value)) {
throw new Error('standingOrders must be an array')
}
return value.map(validateStandingOrder)
}
function isValidTime(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
}
function validateDaysOfWeek(value: unknown): Array<0 | 1 | 2 | 3 | 4 | 5 | 6> {
if (!Array.isArray(value)) {
throw new Error('schedule.daysOfWeek must be an array')
}
return value.map((day) => {
if (
typeof day !== 'number' ||
!Number.isInteger(day) ||
day < 0 ||
day > 6
) {
throw new Error('schedule.daysOfWeek must contain values from 0 to 6')
}
return day as 0 | 1 | 2 | 3 | 4 | 5 | 6
})
}
function validateSchedule(value: unknown): BrowserOSProgramSchedule {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new Error('schedule is required')
}
switch (value.type) {
case 'manual':
return { type: 'manual' }
case 'daily': {
assertNonEmptyString(value.time, 'schedule.time')
if (!isValidTime(value.time)) {
throw new Error('schedule.time must be in HH:MM format')
}
return {
type: 'daily',
time: value.time,
daysOfWeek:
value.daysOfWeek === undefined
? undefined
: validateDaysOfWeek(value.daysOfWeek),
}
}
case 'hourly':
case 'minutes': {
if (
typeof value.interval !== 'number' ||
!Number.isInteger(value.interval) ||
value.interval < 1
) {
throw new Error('schedule.interval must be an integer >= 1')
}
return {
type: value.type,
interval: value.interval,
}
}
default:
throw new Error('schedule.type is invalid')
}
}
export function validateCreateProgramInput(
value: unknown,
): CreateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
assertNonEmptyString(value.name, 'name')
assertNonEmptyString(value.description, 'description')
assertNonEmptyString(value.prompt, 'prompt')
return {
name: value.name.trim(),
description: value.description.trim(),
prompt: value.prompt.trim(),
schedule: validateSchedule(value.schedule),
enabled: value.enabled === undefined ? true : !!value.enabled,
standingOrders: validateStandingOrders(value.standingOrders) ?? [],
}
}
export function validateUpdateProgramInput(
value: unknown,
): UpdateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
const output: UpdateAgentProgramInput = {}
if (value.name !== undefined) {
assertNonEmptyString(value.name, 'name')
output.name = value.name.trim()
}
if (value.description !== undefined) {
assertNonEmptyString(value.description, 'description')
output.description = value.description.trim()
}
if (value.prompt !== undefined) {
assertNonEmptyString(value.prompt, 'prompt')
output.prompt = value.prompt.trim()
}
if (value.enabled !== undefined) {
if (typeof value.enabled !== 'boolean') {
throw new Error('enabled must be a boolean')
}
output.enabled = value.enabled
}
if (value.schedule !== undefined) {
output.schedule = validateSchedule(value.schedule)
}
if (value.standingOrders !== undefined) {
output.standingOrders = validateStandingOrders(value.standingOrders)
}
if (Object.keys(output).length === 0) {
throw new Error('At least one program field must be provided')
}
return output
}

View File

@@ -1,200 +0,0 @@
import {
type BROWSEROS_ROLE_TEMPLATES,
getBrowserOSRoleTemplate,
} from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
BrowserOSRoleTemplate,
} from '@browseros/shared/types/role-aware-agents'
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
interface BootstrapRenderableRole {
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
boundaries: BrowserOSRoleTemplate['boundaries']
bootstrap: BrowserOSRoleTemplate['bootstrap']
}
export interface RoleBootstrapFiles {
'AGENTS.md': string
'SOUL.md': string
'TOOLS.md': string
'.browseros-role.json': string
}
export function resolveRoleTemplate(
roleId: BrowserOSAgentRoleId,
): RoleTemplate {
const role = getBrowserOSRoleTemplate(roleId)
if (!role) {
throw new Error(`Unknown BrowserOS role: ${roleId}`)
}
return role
}
export function buildRoleBootstrapFiles(input: {
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
agentName: string
}): RoleBootstrapFiles {
const normalizedRole = normalizeRoleForBootstrap(input.role)
const roleId = 'id' in input.role ? input.role.id : undefined
return {
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
'SOUL.md': normalizedRole.bootstrap.soulMd,
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
'.browseros-role.json': `${JSON.stringify(
{
version: 1,
roleSource: roleId ? 'builtin' : 'custom',
roleId,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
createdBy: 'browseros',
agentName: input.agentName,
},
null,
2,
)}\n`,
}
}
export function toRoleSummary(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BrowserOSAgentRoleSummary {
const normalizedRole = normalizeRoleForBootstrap(role)
return {
roleSource: 'id' in role ? 'builtin' : 'custom',
roleId: 'id' in role ? role.id : undefined,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
}
}
export function normalizeCustomRole(
role: BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
const recommendedApps = Array.isArray(role.recommendedApps)
? role.recommendedApps.filter(
(app): app is string => typeof app === 'string',
)
: []
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
return {
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps,
boundaries,
bootstrap: {
agentsMd:
role.bootstrap?.agentsMd?.trim() ||
buildAgentsMd({
name: role.name,
longDescription: role.longDescription,
boundaries,
}),
soulMd:
role.bootstrap?.soulMd?.trim() ||
buildSoulMd({
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
}),
toolsMd:
role.bootstrap?.toolsMd?.trim() ||
buildToolsMd({
boundaries,
recommendedApps,
}),
},
}
}
function normalizeRoleForBootstrap(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
return 'id' in role ? role : normalizeCustomRole(role)
}
function buildAgentsMd(input: {
name: string
longDescription: string
boundaries: BrowserOSRoleTemplate['boundaries']
}): string {
const boundaryLines = input.boundaries
.map(
(boundary) =>
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
)
.join('\n')
return `# ${input.name}
You are the ${input.name} specialist for this workspace.
## Core Purpose
${input.longDescription}
## Operating Rules
${boundaryLines}
## Default Output Style
- concise
- action-oriented
- explicit about blockers and approvals
`
}
function buildSoulMd(input: {
name: string
shortDescription: string
longDescription: string
}): string {
return `# Operating Style
You act like a trusted ${input.name}.
## Working Posture
- calm
- structured
- direct
- explicit about tradeoffs
## Role Framing
${input.shortDescription}
${input.longDescription}
`
}
function buildToolsMd(input: {
boundaries: BrowserOSRoleTemplate['boundaries']
recommendedApps: string[]
}): string {
const boundaryLines = input.boundaries
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
.join('\n')
const appsLine =
input.recommendedApps.length > 0
? input.recommendedApps.join(', ')
: 'No specific apps configured yet.'
return `# Tooling Guidelines
- Use BrowserOS MCP for browser and connected SaaS tasks.
- Prefer read, summarize, and draft flows.
- Keep outputs in the workspace when possible so work remains inspectable.
## Recommended Apps
${appsLine}
## Boundary Defaults
${boundaryLines}
`
}

View File

@@ -1,70 +0,0 @@
import type { WSMessageReceive } from 'hono/ws'
import { z } from 'zod'
const TerminalInputMessageSchema = z.object({
type: z.literal('input'),
data: z.string(),
})
const TerminalResizeMessageSchema = z.object({
type: z.literal('resize'),
cols: z.number().int().positive(),
rows: z.number().int().positive(),
})
const TerminalClientMessageSchema = z.discriminatedUnion('type', [
TerminalInputMessageSchema,
TerminalResizeMessageSchema,
])
const TerminalOutputMessageSchema = z.object({
type: z.literal('output'),
data: z.string(),
})
const TerminalExitMessageSchema = z.object({
type: z.literal('exit'),
exitCode: z.number().int(),
})
const TerminalErrorMessageSchema = z.object({
type: z.literal('error'),
message: z.string(),
})
const TerminalServerMessageSchema = z.discriminatedUnion('type', [
TerminalOutputMessageSchema,
TerminalExitMessageSchema,
TerminalErrorMessageSchema,
])
export type TerminalClientMessage = z.infer<typeof TerminalClientMessageSchema>
export type TerminalServerMessage = z.infer<typeof TerminalServerMessageSchema>
function readSocketMessage(data: WSMessageReceive): string | null {
if (typeof data === 'string') return data
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data)
return null
}
export function parseTerminalClientMessage(
data: WSMessageReceive,
): TerminalClientMessage | null {
const text = readSocketMessage(data)
if (!text) return null
let parsed: unknown
try {
parsed = JSON.parse(text) as unknown
} catch {
return null
}
const result = TerminalClientMessageSchema.safeParse(parsed)
return result.success ? result.data : null
}
export function serializeTerminalServerMessage(
message: TerminalServerMessage,
): string {
return JSON.stringify(TerminalServerMessageSchema.parse(message))
}

View File

@@ -1,93 +0,0 @@
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_TERMINAL_SHELL,
} from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24
const TERMINAL_NAME = 'xterm-256color'
interface TerminalSessionDeps {
containerName: string
podmanPath: string
workingDir: string
onExit: (exitCode: number) => void
onOutput: (data: string) => void
}
export interface TerminalSession {
close(): void
resize(cols: number, rows: number): void
writeInput(data: string): void
}
export function buildTerminalExecCommand(
podmanPath: string,
containerName: string,
workingDir: string,
): string[] {
return [
podmanPath,
'exec',
'-it',
'-w',
workingDir,
containerName,
OPENCLAW_TERMINAL_SHELL,
]
}
export function createTerminalSession(
deps: TerminalSessionDeps,
): TerminalSession {
const decoder = new TextDecoder()
const proc = Bun.spawn(
buildTerminalExecCommand(
deps.podmanPath,
deps.containerName,
deps.workingDir,
),
{
terminal: {
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
data(_terminal, data) {
const chunk = decoder.decode(data, { stream: true })
if (chunk) deps.onOutput(chunk)
},
},
env: { ...process.env, TERM: TERMINAL_NAME },
},
)
let closed = false
void proc.exited.then((exitCode) => {
const trailing = decoder.decode()
if (trailing) deps.onOutput(trailing)
deps.onExit(exitCode)
})
logger.debug('Terminal session created', { workingDir: deps.workingDir })
return {
writeInput(data) {
proc.terminal?.write(data)
},
resize(cols, rows) {
proc.terminal?.resize(cols, rows)
},
close() {
if (closed) return
closed = true
try {
proc.terminal?.close()
proc.kill()
} catch {
logger.debug('Terminal session cleanup failed')
}
logger.debug('Terminal session destroyed')
},
}
}

View File

@@ -36,7 +36,7 @@ export type AgentLLMConfig = z.infer<typeof AgentLLMConfigSchema>
export const ChatRequestSchema = AgentLLMConfigSchema.extend({
conversationId: z.string().uuid(),
message: z.string().optional().default(''),
message: z.string().min(1, 'Message cannot be empty'),
contextWindowSize: z.number().optional(),
browserContext: BrowserContextSchema.optional(),
userSystemPrompt: z.string().optional(),
@@ -46,32 +46,6 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
mode: z.enum(['chat', 'agent']).optional().default('agent'),
origin: z.enum(['sidepanel', 'newtab']).optional().default('sidepanel'),
declinedApps: z.array(z.string()).optional(),
aclRules: z
.array(
z.object({
id: z.string(),
sitePattern: z.string(),
selector: z.string().optional(),
textMatch: z.string().optional(),
description: z.string().optional(),
enabled: z.boolean(),
}),
)
.optional(),
toolApprovalConfig: z
.object({
categories: z.record(z.boolean()),
})
.optional(),
toolApprovalResponses: z
.array(
z.object({
approvalId: z.string(),
approved: z.boolean(),
reason: z.string().optional(),
}),
)
.optional(),
selectedText: z.string().optional(),
selectedTextSource: z
.object({

View File

@@ -1,47 +0,0 @@
import type { MiddlewareHandler } from 'hono'
import { isLocalhostRequest } from './security'
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1'])
const EXTENSION_PROTOCOLS = new Set(['chrome-extension:', 'moz-extension:'])
export function isTrustedAppOrigin(origin: string | undefined): boolean {
if (!origin) return false
try {
const url = new URL(origin)
if (
(url.protocol === 'http:' || url.protocol === 'https:') &&
LOOPBACK_HOSTS.has(url.hostname)
) {
return true
}
return EXTENSION_PROTOCOLS.has(url.protocol)
} catch {
return false
}
}
export function requireTrustedAppOrigin(): MiddlewareHandler {
return async (c, next) => {
const origin = c.req.header('origin')
if (origin) {
if (!isTrustedAppOrigin(origin)) {
return c.json({ error: 'Forbidden' }, 403)
}
return next()
}
// Some local reads arrive without an Origin header. Allow those only when
// the actual client socket is loopback. This avoids Host-header spoofing.
if (
['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) &&
isLocalhostRequest(c)
) {
return next()
}
return c.json({ error: 'Forbidden' }, 403)
}
}

View File

@@ -1,5 +1,4 @@
import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api'
import type { ElementProperties } from '@browseros/shared/types/acl'
import { logger } from '../lib/logger'
import type { CdpBackend } from './backends/types'
import type { BookmarkNode } from './bookmarks'
@@ -86,24 +85,6 @@ const EXCLUDED_URL_PREFIXES = [
'devtools://',
]
const ACTIONABLE_SELECTOR = [
'button',
'a[href]',
'input',
'select',
'textarea',
'summary',
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="option"]',
'[onclick]',
'[tabindex]',
].join(',')
export class Browser {
private cdp: CdpBackend
private consoleCollector: ConsoleCollector
@@ -245,253 +226,6 @@ export class Browser {
return this.pages.get(pageId)?.tabId
}
getPageInfo(pageId: number): PageInfo | undefined {
return this.pages.get(pageId)
}
async refreshPageInfo(pageId: number): Promise<PageInfo | undefined> {
let info = this.pages.get(pageId)
if (!info) {
await this.listPages()
info = this.pages.get(pageId)
}
if (!info) return undefined
try {
const result = await this.cdp.Browser.getTabInfo({ tabId: info.tabId })
const tab = result.tab as TabInfo
const updated: PageInfo = {
...info,
targetId: tab.targetId,
tabId: tab.tabId,
url: tab.url,
title: tab.title,
isActive: tab.isActive,
isLoading: tab.isLoading,
loadProgress: tab.loadProgress,
isPinned: tab.isPinned,
isHidden: tab.isHidden,
windowId: tab.windowId,
index: tab.index,
groupId: tab.groupId,
}
this.pages.set(pageId, updated)
return updated
} catch {
await this.listPages()
return this.pages.get(pageId)
}
}
async getSession(pageId: number): Promise<ProtocolApi | null> {
const info = this.pages.get(pageId)
if (!info) return null
const sessionId = this.sessions.get(info.targetId)
if (!sessionId) return null
return this.cdp.session(sessionId)
}
async resolveActionableElement(
pageId: number,
backendNodeId: number,
): Promise<number | null> {
const session = await this.resolveSession(pageId)
try {
const resolved = await session.DOM.resolveNode({ backendNodeId })
const objectId = resolved.object?.objectId
if (!objectId) return backendNodeId
const actionable = await session.Runtime.callFunctionOn({
functionDeclaration: `function(selector){
var element = this instanceof Element
? this
: this && this.parentElement
? this.parentElement
: this && this.parentNode instanceof Element
? this.parentNode
: null;
if (!element) return null;
return element.closest(selector) || element;
}`,
objectId,
arguments: [{ value: ACTIONABLE_SELECTOR }],
})
const actionableObjectId = actionable.result?.objectId
if (!actionableObjectId) return backendNodeId
const desc = await session.DOM.describeNode({
objectId: actionableObjectId,
})
return desc.node?.backendNodeId ?? backendNodeId
} catch {
return null
}
}
async resolveElementAtPoint(
pageId: number,
x: number,
y: number,
): Promise<number | null> {
const session = await this.resolveSession(pageId)
try {
const fromDom = await session.Runtime.evaluate({
expression: `document.elementFromPoint(${Math.round(x)}, ${Math.round(y)})`,
})
const objectId = fromDom.result?.objectId
if (objectId) {
const desc = await session.DOM.describeNode({ objectId })
const backendNodeId = desc.node?.backendNodeId
if (backendNodeId) {
return await this.resolveActionableElement(pageId, backendNodeId)
}
}
} catch {
// fall through to CDP hit-testing
}
try {
const located = await session.DOM.getNodeForLocation({
x: Math.round(x),
y: Math.round(y),
includeUserAgentShadowDOM: true,
ignorePointerEventsNone: true,
})
return await this.resolveActionableElement(pageId, located.backendNodeId)
} catch {
return null
}
}
async resolveElementProperties(
pageId: number,
backendNodeId: number,
): Promise<ElementProperties | null> {
const session = await this.resolveSession(pageId)
try {
const targetNodeId =
(await this.resolveActionableElement(pageId, backendNodeId)) ??
backendNodeId
const desc = await session.DOM.describeNode({
backendNodeId: targetNodeId,
depth: 0,
})
const node = desc.node
const attrs = parseNodeAttributes(node)
const resolved = await session.DOM.resolveNode({
backendNodeId: targetNodeId,
})
const objectId = resolved.object?.objectId
let textContent = ''
let labelText = ''
if (objectId) {
const textResult = await session.Runtime.callFunctionOn({
functionDeclaration: `function(){
var text = (this.innerText || this.textContent || '').trim();
var aria = this.getAttribute('aria-label') || '';
var placeholder = this.getAttribute('placeholder') || '';
var title = this.getAttribute('title') || '';
var value = typeof this.value === 'string' ? this.value : '';
var labels = Array.from(this.labels || [])
.map(function(label){ return (label.innerText || label.textContent || '').trim(); })
.filter(Boolean)
.join(' ');
return {
textContent: text.substring(0, 200),
labelText: [aria, labels, placeholder, title, value, text]
.filter(Boolean)
.join(' ')
.trim()
.substring(0, 400),
};
}`,
objectId,
returnByValue: true,
})
const value = (textResult.result?.value ?? {}) as {
textContent?: string
labelText?: string
}
textContent = value.textContent ?? ''
labelText = value.labelText ?? ''
}
return {
tagName: node.localName ?? '',
textContent,
attributes: attrs,
labelText,
ariaLabel: attrs['aria-label'],
role: attrs.role,
}
} catch {
return null
}
}
async highlightBlockedElement(
pageId: number,
backendNodeId: number,
reason: string,
): Promise<void> {
const session = await this.resolveSession(pageId)
const targetNodeId =
(await this.resolveActionableElement(pageId, backendNodeId)) ??
backendNodeId
try {
const resolved = await session.DOM.resolveNode({
backendNodeId: targetNodeId,
})
const objectId = resolved.object?.objectId
if (!objectId) return
await session.Runtime.callFunctionOn({
functionDeclaration: `function(reason){
var existing = document.getElementById('__browseros_acl_block_overlay');
if (existing) existing.remove();
var existingStyle = document.getElementById('__browseros_acl_block_style');
if (!existingStyle) {
var style = document.createElement('style');
style.id = '__browseros_acl_block_style';
style.textContent = [
'#__browseros_acl_block_overlay{position:absolute;pointer-events:none;z-index:2147483647;}',
'#__browseros_acl_block_overlay .ring{position:absolute;inset:0;border:2px solid rgba(220,38,38,0.95);background:rgba(220,38,38,0.14);border-radius:10px;box-shadow:0 0 0 3px rgba(255,255,255,0.75);}',
'#__browseros_acl_block_overlay .badge{position:absolute;top:-10px;right:-10px;background:rgba(153,27,27,0.96);color:white;font:600 11px/1.2 system-ui,sans-serif;padding:6px 8px;border-radius:999px;white-space:nowrap;box-shadow:0 6px 18px rgba(0,0,0,0.2);}',
].join('');
document.head.appendChild(style);
}
var rect = this.getBoundingClientRect();
if (!rect.width || !rect.height) return;
var overlay = document.createElement('div');
overlay.id = '__browseros_acl_block_overlay';
overlay.style.left = (rect.left + window.scrollX) + 'px';
overlay.style.top = (rect.top + window.scrollY) + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
var ring = document.createElement('div');
ring.className = 'ring';
var badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = reason || 'Blocked';
overlay.appendChild(ring);
overlay.appendChild(badge);
document.body.appendChild(overlay);
window.setTimeout(function(){
var current = document.getElementById('__browseros_acl_block_overlay');
if (current) current.remove();
}, 2500);
}`,
objectId,
arguments: [{ value: reason }],
})
} catch {
// best-effort visual feedback
}
}
async resolveTabIds(tabIds: number[]): Promise<Map<number, number>> {
await this.listPages()
const tabToPage = new Map<number, number>()

View File

@@ -34,10 +34,6 @@ export function getBuiltinSkillsDir(): string {
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
}
export function getOpenClawDir(): string {
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}

View File

@@ -13,7 +13,6 @@ import fs from 'node:fs'
import path from 'node:path'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { createHttpServer } from './api/server'
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
import { CdpBackend } from './browser/backends/cdp'
import { Browser } from './browser/browser'
import type { ServerConfig } from './config'
@@ -119,23 +118,12 @@ export class Application {
this.logStartupSummary()
startSkillSync()
getOpenClawService(this.config.serverPort)
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
metrics.log('http_server.started', { version: VERSION })
}
stop(reason?: string): void {
logger.info('Shutting down server...', { reason })
stopSkillSync()
getOpenClawService()
.shutdown()
.catch(() => {})
removeServerConfigSync()
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,

View File

@@ -1,55 +0,0 @@
# ACL Matcher
The ACL matcher blocks guarded tool actions (click, fill, hover, etc.) when they target elements that match user-defined access control rules. It scores each rule against the target element using a combination of exact, fuzzy, and semantic similarity — then blocks if the confidence exceeds a threshold.
## How it works
When a guarded tool is invoked, `acl-guard.ts` resolves the target element's properties (text content, aria labels, attributes, etc.) and runs them through the scoring pipeline:
1. **Site filtering** — rules are filtered to those matching the current page URL
2. **Site-only rules** — rules with no selector/text/description block the entire site immediately
3. **Element scoring** — remaining rules are scored against the element using three signals:
| Signal | Weight | How it works |
|--------|--------|-------------|
| Exact | 25% | Are any compiled rule terms a substring of an element field? |
| Fuzzy | 25% | Edit distance ratio between rule terms and element text windows |
| Semantic | 50% | Cosine similarity of sentence embeddings (BAAI/bge-small-en-v1.5 via ONNX) |
The weighted scores produce a **confidence** value between 0 and 1. If confidence >= **0.4** (Handpicked, needs updating), the action is blocked.
## Files
| File | Purpose |
|------|---------|
| `acl-guard.ts` | Entry point — called by `framework.ts` during tool execution |
| `acl-scorer.ts` | Core pipeline: text normalization, feature extraction, scoring, decision |
| `acl-embeddings.ts` | Lazy-loaded `@huggingface/transformers` pipeline for semantic similarity |
| `acl-edit-distance.ts` | Levenshtein edit distance ratio for fuzzy matching |
| `acl-stopwords.ts` | Static set of 198 English stopwords (from NLTK corpus) |
Shared types and basic matchers live in `packages/shared/`:
- `src/types/acl.ts``AclRule` and `ElementProperties` interfaces
- `src/acl/match.ts` — site pattern globbing and CSS selector matching
## Embedding model
The semantic scoring uses [BAAI/bge-small-en-v1.5](https://huggingface.co/BAAI/bge-small-en-v1.5) (~33MB ONNX model) via `@huggingface/transformers`. The model downloads automatically on first use and is cached for the process lifetime.
Override the model with the `ACL_EMBEDDING_MODEL` environment variable (e.g. `ACL_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5`).
## Testing
```bash
bun --env-file=.env.development test apps/server/tests/tools/acl-scorer.test.ts
```
Test fixtures live in `apps/server/tests/__fixtures__/acl/` (courtesy of claude code):
| Fixture | Tests |
|---------|-------|
| `submit-button.json` | Exact match — "Place Order" button vs "block checkout submit" rule |
| `semantic-payment.json` | Semantic match — "Proceed to Checkout" vs "prevent purchase actions" |
| `semantic-delete.json` | Semantic match — "Remove my account permanently" vs "block account deletion" |
| `semantic-send-email.json` | Semantic match — send button vs "do not dispatch emails" |
| `semantic-safe.json` | False positive — "View Report" should NOT be blocked by payment/delete rules |

View File

@@ -1,25 +0,0 @@
export function editDistanceRatio(a: string, b: string): number {
const maxLength = Math.max(a.length, b.length)
if (maxLength === 0) return 1.0
let previousRow = Array.from({ length: b.length + 1 }, (_, index) => index)
let currentRow = new Array<number>(b.length + 1)
for (let i = 1; i <= a.length; i++) {
currentRow[0] = i
for (let j = 1; j <= b.length; j++) {
const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1
currentRow[j] = Math.min(
previousRow[j] + 1,
currentRow[j - 1] + 1,
previousRow[j - 1] + substitutionCost,
)
}
;[previousRow, currentRow] = [currentRow, previousRow]
}
const distance = previousRow[b.length]
return 1.0 - distance / maxLength
}

View File

@@ -1,88 +0,0 @@
import { logger } from '../../lib/logger'
interface SemanticScore {
score: number
backend: string
}
type FeatureExtractionPipeline = (
texts: string[],
options: { pooling: string; normalize: boolean },
) => Promise<{ tolist: () => number[][] }>
let pipelineInstance: FeatureExtractionPipeline | null = null
const LOAD_RETRY_MS = 60_000
let lastLoadFailedAt = 0
function getModelName(): string {
return process.env.ACL_EMBEDDING_MODEL ?? 'Xenova/bge-small-en-v1.5'
}
async function ensurePipeline(): Promise<FeatureExtractionPipeline | null> {
if (pipelineInstance) return pipelineInstance
if (lastLoadFailedAt > 0 && Date.now() - lastLoadFailedAt < LOAD_RETRY_MS) {
return null
}
try {
const { pipeline } = await import('@huggingface/transformers')
const extractor = await pipeline('feature-extraction', getModelName(), {
dtype: 'fp32',
})
pipelineInstance = extractor as unknown as FeatureExtractionPipeline
lastLoadFailedAt = 0
logger.info('ACL embedding model loaded', { model: getModelName() })
return pipelineInstance
} catch (error) {
lastLoadFailedAt = Date.now()
logger.warn(
'ACL embedding model failed to load, semantic scoring disabled',
{
model: getModelName(),
error: error instanceof Error ? error.message : String(error),
},
)
return null
}
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0
let normA = 0
let normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
export async function computeSemanticSimilarity(
left: string,
right: string,
): Promise<SemanticScore> {
if (!left || !right) return { score: 0, backend: 'none' }
const extractor = await ensurePipeline()
if (!extractor) return { score: 0, backend: 'error' }
try {
const output = await extractor([left, right], {
pooling: 'cls',
normalize: true,
})
const embeddings = output.tolist()
const score = cosineSimilarity(embeddings[0], embeddings[1])
return {
score: Math.max(0, Math.min(score, 1)),
backend: 'transformers.js',
}
} catch (error) {
logger.warn('ACL semantic similarity computation failed', {
error: error instanceof Error ? error.message : String(error),
})
return { score: 0, backend: 'error' }
}
}

View File

@@ -1,127 +0,0 @@
import { matchesSitePattern } from '@browseros/shared/acl/match'
import type { AclRule } from '@browseros/shared/types/acl'
import type { Browser } from '../../browser/browser'
import { logger } from '../../lib/logger'
import { scoreFixture } from './acl-scorer'
const GUARDED_TOOLS = new Set([
'click',
'click_at',
'fill',
'type_at',
'hover',
'hover_at',
'drag',
'drag_at',
'focus',
'clear',
'check',
'uncheck',
'select_option',
'press_key',
'upload_file',
])
export interface AclCheckResult {
blocked: boolean
rule?: AclRule
pageId?: number
elementId?: number
}
async function resolveTargetElementId(
toolName: string,
args: Record<string, unknown>,
browser: Browser,
pageId: number,
): Promise<number | undefined> {
if (typeof args.element === 'number') return args.element
if (toolName === 'drag' && typeof args.sourceElement === 'number') {
return args.sourceElement
}
if (typeof args.x === 'number' && typeof args.y === 'number') {
return (
(await browser.resolveElementAtPoint(pageId, args.x, args.y)) ?? undefined
)
}
if (
toolName === 'drag_at' &&
typeof args.startX === 'number' &&
typeof args.startY === 'number'
) {
return (
(await browser.resolveElementAtPoint(pageId, args.startX, args.startY)) ??
undefined
)
}
return undefined
}
export async function checkAcl(
toolName: string,
args: Record<string, unknown>,
browser: Browser,
rules: AclRule[],
): Promise<AclCheckResult> {
if (!GUARDED_TOOLS.has(toolName)) return { blocked: false }
if (!rules.length) return { blocked: false }
const pageId = args.page as number | undefined
if (pageId === undefined) return { blocked: false }
const pageInfo = await browser.refreshPageInfo(pageId)
if (!pageInfo) return { blocked: false }
const siteRules = rules.filter((r) =>
matchesSitePattern(pageInfo.url, r.sitePattern),
)
if (!siteRules.length) return { blocked: false }
const siteOnlyRule = siteRules.find(
(r) => !r.selector && !r.textMatch && !r.description,
)
if (siteOnlyRule) {
logger.info('ACL blocked by site-only rule', {
toolName,
pageId,
pageUrl: pageInfo.url,
ruleId: siteOnlyRule.id,
sitePattern: siteOnlyRule.sitePattern,
})
return { blocked: true, rule: siteOnlyRule, pageId }
}
const elementId = await resolveTargetElementId(
toolName,
args,
browser,
pageId,
)
if (elementId === undefined) return { blocked: false }
const props = await browser.resolveElementProperties(pageId, elementId)
if (!props) return { blocked: false }
const decision = await scoreFixture(toolName, pageInfo.url, props, siteRules)
if (decision.blocked) {
const matchedRule = decision.matchedRuleId
? rules.find((rule) => rule.id === decision.matchedRuleId)
: undefined
logger.info('ACL blocked by scorer', {
toolName,
pageId,
pageUrl: pageInfo.url,
elementId,
ruleId: decision.matchedRuleId,
confidence: decision.confidence,
reason: decision.reason,
})
return { blocked: true, rule: matchedRule, pageId, elementId }
}
return { blocked: false }
}

View File

@@ -1,399 +0,0 @@
import { matchesSitePattern } from '@browseros/shared/acl/match'
import type { AclRule, ElementProperties } from '@browseros/shared/types/acl'
import { logger } from '../../lib/logger'
import { editDistanceRatio } from './acl-edit-distance'
import { computeSemanticSimilarity } from './acl-embeddings'
import { NLTK_STOP_WORDS } from './acl-stopwords'
const EXACT_WEIGHT = 0.25
const FUZZY_WEIGHT = 0.25
const SEMANTIC_WEIGHT = 0.5
const BLOCK_THRESHOLD = 0.4
export interface RuleScore {
ruleId: string
blocked: boolean
confidence: number
exactScore: number
fuzzyScore: number
semanticScore: number
semanticBackend: string
selectorMatched: boolean
siteMatched: boolean
reason: string
matchedTerms: string[]
}
export interface MatchDecision {
blocked: boolean
toolName: string
pageUrl: string
matchedRuleId: string | null
confidence: number
reason: string
candidates: RuleScore[]
}
interface RuleMatchInputs {
terms: string[]
ruleText: string
elementFields: string[]
elementText: string
}
// --- Text normalization ---
function splitIdentifierWords(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
}
function normalizeText(value: string): string {
return splitIdentifierWords(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
}
function tokenizeWords(value: string): string[] {
return normalizeText(value)
.split(/\s+/)
.filter((t) => t.length > 0 && /^[a-z0-9]+$/.test(t))
}
function normalizeTerm(term: string): string {
return tokenizeWords(term).join(' ')
}
function dedupe(values: Iterable<string>): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const v of values) {
if (v && !seen.has(v)) {
seen.add(v)
result.push(v)
}
}
return result
}
function dedupeTextTokens(value: string): string {
return dedupe(value.split(/\s+/)).join(' ')
}
// --- Selector matching ---
function selectorMatchesProps(
selector: string,
props: ElementProperties,
): boolean {
const tag = props.tagName.toLowerCase()
const id = props.attributes.id
const classes = (props.attributes.class ?? '').split(/\s+/).filter(Boolean)
for (const raw of selector.split(',')) {
const part = raw.trim()
if (!part) continue
if (part.startsWith('#') && id && part === `#${id}`) return true
if (part.startsWith('.') && classes.some((c) => part === `.${c}`))
return true
const match = part.match(/^(\w+)$/)
if (match && match[1].toLowerCase() === tag) return true
}
return false
}
// --- Feature extraction ---
function extractHostTerms(pattern: string): Set<string> {
const host = pattern.includes('/') ? pattern.split('/')[0] : pattern
const normalized = tokenizeWords(host.replace(/\*/g, ' '))
return new Set(normalized.filter((t) => t.length >= 3))
}
function compileRuleTerms(rule: AclRule): string[] {
const terms: string[] = []
const textMatch = normalizeTerm(rule.textMatch ?? '')
if (textMatch) terms.push(textMatch)
const descriptionRaw = rule.description ?? ''
const description = normalizeTerm(descriptionRaw)
if (!description) return dedupe(terms)
terms.push(description)
const hostTerms = extractHostTerms(rule.sitePattern)
const descTokens = tokenizeWords(descriptionRaw)
const rawTerms = descTokens.filter(
(t) => t.length >= 3 && !NLTK_STOP_WORDS.has(t) && !hostTerms.has(t),
)
terms.push(...rawTerms)
// Make 2-grams and 3-grams from user-provided rules
for (const window of [2, 3]) {
if (rawTerms.length < window) continue
for (let start = 0; start <= rawTerms.length - window; start++) {
terms.push(rawTerms.slice(start, start + window).join(' '))
}
}
return dedupe(terms)
}
function buildRuleText(rule: AclRule): string {
return normalizeText([rule.textMatch ?? '', rule.description ?? ''].join(' '))
}
function buildSearchFields(props: ElementProperties): string[] {
const attrs = props.attributes ?? {}
const rawFields = [
props.labelText ?? '',
props.ariaLabel ?? '',
props.textContent,
attrs.placeholder ?? '',
attrs.title ?? '',
attrs.name ?? '',
attrs.value ?? '',
attrs.id ?? '',
]
return dedupe(rawFields.filter(Boolean).map(normalizeTerm))
}
function buildSearchText(props: ElementProperties): string {
return dedupeTextTokens(
[...buildSearchFields(props), normalizeTerm(props.role ?? '')]
.filter(Boolean)
.join(' '),
)
}
function buildRuleMatchInputs(
rule: AclRule,
props: ElementProperties,
): RuleMatchInputs {
return {
terms: compileRuleTerms(rule),
ruleText: buildRuleText(rule),
elementFields: buildSearchFields(props),
elementText: buildSearchText(props),
}
}
// --- Similarity scoring ---
function phraseWindows(text: string, phraseTokenCount: number): string[] {
const tokens = text.split(/\s+/).filter(Boolean)
if (tokens.length === 0) return []
if (phraseTokenCount <= 1) return tokens
if (tokens.length <= phraseTokenCount) return [tokens.join(' ')]
const windows: string[] = []
for (let i = 0; i <= tokens.length - phraseTokenCount; i++) {
windows.push(tokens.slice(i, i + phraseTokenCount).join(' '))
}
return windows
}
function exactScore(terms: string[], fields: string[]): [number, string[]] {
const matched = terms.filter((term) =>
fields.some((field) => term && field?.includes(term)),
)
return [matched.length > 0 ? 1.0 : 0.0, dedupe(matched)]
}
function fuzzyScore(terms: string[], fields: string[]): number {
let best = 0
for (const term of terms) {
const tokenCount = Math.max(term.split(/\s+/).length, 1)
for (const field of fields) {
const candidates = phraseWindows(field, tokenCount)
if (candidates.length === 0) candidates.push(field)
for (const candidate of candidates) {
best = Math.max(best, editDistanceRatio(term, candidate))
}
}
}
return best
}
function weightedScore(exact: number, fuzzy: number, semantic: number): number {
return (
EXACT_WEIGHT * exact + FUZZY_WEIGHT * fuzzy + SEMANTIC_WEIGHT * semantic
)
}
// --- Rule scoring ---
function hasContentFilter(rule: AclRule): boolean {
return Boolean(rule.selector || rule.textMatch || rule.description)
}
function scoreSelectorMismatch(rule: AclRule): RuleScore {
return {
ruleId: rule.id,
blocked: false,
confidence: 0,
exactScore: 0,
fuzzyScore: 0,
semanticScore: 0,
semanticBackend: 'none',
selectorMatched: false,
siteMatched: true,
reason: 'selector-mismatch',
matchedTerms: [],
}
}
function scoreSiteOnlyRule(rule: AclRule, selectorMatched: boolean): RuleScore {
return {
ruleId: rule.id,
blocked: true,
confidence: 1,
exactScore: 1,
fuzzyScore: 1,
semanticScore: 1,
semanticBackend: 'site-only',
selectorMatched,
siteMatched: true,
reason: 'site-only-rule',
matchedTerms: [],
}
}
function scoreSelectorOnlyRule(
rule: AclRule,
selectorMatched: boolean,
): RuleScore {
const confidence = selectorMatched ? 1 : 0
return {
ruleId: rule.id,
blocked: selectorMatched,
confidence,
exactScore: confidence,
fuzzyScore: confidence,
semanticScore: confidence,
semanticBackend: 'selector-only',
selectorMatched,
siteMatched: true,
reason: 'selector-only',
matchedTerms: [],
}
}
function determineMatchReason(exact: number, confidence: number): string {
if (exact >= 1.0) return 'exact-term-match'
if (confidence >= BLOCK_THRESHOLD) return 'weighted-match'
return 'below-threshold'
}
async function scoreRule(
pageUrl: string,
props: ElementProperties,
rule: AclRule,
): Promise<RuleScore | null> {
if (rule.enabled === false) return null
if (!matchesSitePattern(pageUrl, rule.sitePattern)) return null
let selectorMatched = true
if (rule.selector) {
selectorMatched = selectorMatchesProps(rule.selector, props)
if (!selectorMatched) return scoreSelectorMismatch(rule)
}
if (!hasContentFilter(rule)) return scoreSiteOnlyRule(rule, selectorMatched)
const inputs = buildRuleMatchInputs(rule, props)
if (inputs.terms.length === 0)
return scoreSelectorOnlyRule(rule, selectorMatched)
const [exact, matchedTerms] = exactScore(inputs.terms, inputs.elementFields)
const fuzzy = fuzzyScore(inputs.terms, inputs.elementFields)
const semantic = await computeSemanticSimilarity(
inputs.ruleText,
inputs.elementText,
)
const confidence =
Math.round(weightedScore(exact, fuzzy, semantic.score) * 10000) / 10000
const result: RuleScore = {
ruleId: rule.id,
blocked: confidence >= BLOCK_THRESHOLD,
confidence,
exactScore: Math.round(exact * 10000) / 10000,
fuzzyScore: Math.round(fuzzy * 10000) / 10000,
semanticScore: Math.round(semantic.score * 10000) / 10000,
semanticBackend: semantic.backend,
selectorMatched,
siteMatched: true,
reason: determineMatchReason(exact, confidence),
matchedTerms,
}
logger.debug('ACL rule scored', {
ruleId: result.ruleId,
reason: result.reason,
confidence: result.confidence,
exact: result.exactScore,
fuzzy: result.fuzzyScore,
semantic: result.semanticScore,
semanticBackend: result.semanticBackend,
})
return result
}
export async function scoreFixture(
toolName: string,
pageUrl: string,
element: ElementProperties,
rules: AclRule[],
): Promise<MatchDecision> {
const candidates: RuleScore[] = []
for (const rule of rules) {
const score = await scoreRule(pageUrl, element, rule)
if (score) candidates.push(score)
}
candidates.sort((a, b) => b.confidence - a.confidence)
const top = candidates[0]
const decision: MatchDecision = {
blocked: top?.blocked ?? false,
toolName,
pageUrl,
matchedRuleId: top?.blocked ? top.ruleId : null,
confidence: top?.confidence ?? 0,
reason: top?.reason ?? 'no-matching-rules',
candidates,
}
if (candidates.some((candidate) => candidate.semanticBackend === 'error')) {
logger.warn('ACL decision computed without semantic scoring', {
toolName,
pageUrl,
candidateCount: candidates.length,
})
}
if (decision.blocked) {
logger.info('ACL BLOCKED', {
toolName,
pageUrl,
ruleId: decision.matchedRuleId,
confidence: decision.confidence,
reason: decision.reason,
})
} else {
logger.debug('ACL ALLOWED', { toolName, pageUrl, reason: decision.reason })
}
return decision
}

View File

@@ -1,200 +0,0 @@
export const NLTK_STOP_WORDS = new Set([
'a',
'about',
'above',
'after',
'again',
'against',
'ain',
'all',
'am',
'an',
'and',
'any',
'are',
'aren',
"aren't",
'as',
'at',
'be',
'because',
'been',
'before',
'being',
'below',
'between',
'both',
'but',
'by',
'can',
'couldn',
"couldn't",
'd',
'did',
'didn',
"didn't",
'do',
'does',
'doesn',
"doesn't",
'doing',
'don',
"don't",
'down',
'during',
'each',
'few',
'for',
'from',
'further',
'had',
'hadn',
"hadn't",
'has',
'hasn',
"hasn't",
'have',
'haven',
"haven't",
'having',
'he',
"he'd",
"he'll",
"he's",
'her',
'here',
'hers',
'herself',
'him',
'himself',
'his',
'how',
'i',
"i'd",
"i'll",
"i'm",
"i've",
'if',
'in',
'into',
'is',
'isn',
"isn't",
'it',
"it'd",
"it'll",
"it's",
'its',
'itself',
'just',
'll',
'm',
'ma',
'me',
'mightn',
"mightn't",
'more',
'most',
'mustn',
"mustn't",
'my',
'myself',
'needn',
"needn't",
'no',
'nor',
'not',
'now',
'o',
'of',
'off',
'on',
'once',
'only',
'or',
'other',
'our',
'ours',
'ourselves',
'out',
'over',
'own',
're',
's',
'same',
'shan',
"shan't",
'she',
"she'd",
"she'll",
"she's",
'should',
"should've",
'shouldn',
"shouldn't",
'so',
'some',
'such',
't',
'than',
'that',
"that'll",
'the',
'their',
'theirs',
'them',
'themselves',
'then',
'there',
'these',
'they',
"they'd",
"they'll",
"they're",
"they've",
'this',
'those',
'through',
'to',
'too',
'under',
'until',
'up',
've',
'very',
'was',
'wasn',
"wasn't",
'we',
"we'd",
"we'll",
"we're",
"we've",
'were',
'weren',
"weren't",
'what',
'when',
'where',
'which',
'while',
'who',
'whom',
'why',
'will',
'with',
'won',
"won't",
'wouldn',
"wouldn't",
'y',
'you',
"you'd",
"you'll",
"you're",
"you've",
'your',
'yours',
'yourself',
'yourselves',
])

View File

@@ -1,8 +1,7 @@
import { z } from 'zod'
import type { BookmarkNode } from '../browser/bookmarks'
import { defineToolWithCategory } from './framework'
import { defineTool } from './framework'
const defineManagementTool = defineToolWithCategory('data-modification')
const bookmarkNodeSchema = z.object({
id: z.string(),
title: z.string(),
@@ -27,7 +26,7 @@ function formatBookmarkTree(nodes: BookmarkNode[]): string {
return lines.join('\n')
}
export const get_bookmarks = defineManagementTool({
export const get_bookmarks = defineTool({
name: 'get_bookmarks',
description: 'List all bookmarks in the browser',
input: z.object({}),
@@ -49,7 +48,7 @@ export const get_bookmarks = defineManagementTool({
},
})
export const create_bookmark = defineManagementTool({
export const create_bookmark = defineTool({
name: 'create_bookmark',
description: 'Create a new bookmark or folder. Omit url to create a folder.',
input: z.object({
@@ -77,7 +76,7 @@ export const create_bookmark = defineManagementTool({
},
})
export const remove_bookmark = defineManagementTool({
export const remove_bookmark = defineTool({
name: 'remove_bookmark',
description: 'Remove a bookmark or folder by ID (recursive)',
input: z.object({
@@ -94,7 +93,7 @@ export const remove_bookmark = defineManagementTool({
},
})
export const update_bookmark = defineManagementTool({
export const update_bookmark = defineTool({
name: 'update_bookmark',
description: 'Update a bookmark title or URL',
input: z.object({
@@ -116,7 +115,7 @@ export const update_bookmark = defineManagementTool({
},
})
export const move_bookmark = defineManagementTool({
export const move_bookmark = defineTool({
name: 'move_bookmark',
description: 'Move a bookmark or folder into a different folder',
input: z.object({
@@ -143,7 +142,7 @@ export const move_bookmark = defineManagementTool({
},
})
export const search_bookmarks = defineManagementTool({
export const search_bookmarks = defineTool({
name: 'search_bookmarks',
description: 'Search bookmarks by title or URL',
input: z.object({

View File

@@ -1,7 +1,5 @@
import { z } from 'zod'
import { defineToolWithCategory } from './framework'
const defineAssistantTool = defineToolWithCategory('assistant')
import { defineTool } from './framework'
const BROWSEROS_INFO = `# BrowserOS — The Open-Source AI Browser
@@ -99,7 +97,7 @@ function getTopicContent(topic: string): string {
: BROWSEROS_INFO.slice(startIdx).trim()
}
export const browseros_info = defineAssistantTool({
export const browseros_info = defineTool({
name: 'browseros_info',
description:
'Get information about BrowserOS features, capabilities, and documentation links. Use when users ask "What is BrowserOS?", "What can BrowserOS do?", or about specific features.',

View File

@@ -1,12 +1,11 @@
import { CONTENT_LIMITS } from '@browseros/shared/constants/limits'
import { z } from 'zod'
import type { ConsoleLevel } from '../browser/console-collector'
import { defineToolWithCategory } from './framework'
import { defineTool } from './framework'
const pageParam = z.number().describe('Page ID (from list_pages)')
const defineObservationTool = defineToolWithCategory('observation')
export const get_console_logs = defineObservationTool({
export const get_console_logs = defineTool({
name: 'get_console_logs',
description:
'Get browser console output (logs, warnings, errors, exceptions) for a page. Use to debug JavaScript errors, failed network requests, or unexpected page behavior.',

View File

@@ -1,12 +1,11 @@
import { z } from 'zod'
import { formatSearchResult } from '../browser/dom'
import { defineToolWithCategory } from './framework'
import { defineTool } from './framework'
import { writeTempToolOutputFile } from './output-file'
const pageParam = z.number().describe('Page ID (from list_pages)')
const defineObservationTool = defineToolWithCategory('observation')
export const get_dom = defineObservationTool({
export const get_dom = defineTool({
name: 'get_dom',
description:
'Get the raw HTML DOM structure of a page or a specific element. Writes outer HTML to a local file and returns the file path. Use a CSS selector to scope to a specific part of the page. For readable text content, prefer get_page_content instead.',
@@ -56,7 +55,7 @@ export const get_dom = defineObservationTool({
},
})
export const search_dom = defineObservationTool({
export const search_dom = defineTool({
name: 'search_dom',
description:
'Search the DOM using plain text, CSS selectors, or XPath queries. Uses the browser\'s native DOM search. Returns matching elements with tag name and attributes. Examples: "Login" (text search), "input[type=email]" (CSS), "//button[@aria-label]" (XPath).',

View File

@@ -1,7 +1,5 @@
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import type { ToolApprovalCategoryId } from '@browseros/shared/constants/tool-approval'
import type { AclRule } from '@browseros/shared/types/acl'
import type { z } from 'zod'
import type { Browser } from '../browser/browser'
import { ToolResponse, type ToolResult } from './response'
@@ -9,7 +7,6 @@ import { ToolResponse, type ToolResult } from './response'
export interface ToolDefinition {
name: string
description: string
approvalCategory: ToolApprovalCategoryId
input: z.ZodType
output?: z.ZodType
handler: ToolHandler
@@ -35,7 +32,6 @@ export type ToolContext = {
browser: Browser
directories: ToolDirectories
session?: ToolSessionContext
aclRules?: AclRule[]
}
export function resolveWorkingPath(
@@ -52,7 +48,6 @@ export function defineTool<
>(config: {
name: string
description: string
approvalCategory: ToolApprovalCategoryId
input: TInput
output?: TOutput
handler: (
@@ -64,29 +59,6 @@ export function defineTool<
return config as ToolDefinition
}
export function defineToolWithCategory(
approvalCategory: ToolApprovalCategoryId,
) {
return <
TInput extends z.ZodType,
TOutput extends z.ZodType | undefined = undefined,
>(config: {
name: string
description: string
input: TInput
output?: TOutput
handler: (
args: z.infer<TInput>,
ctx: ToolContext,
response: ToolResponse,
) => Promise<void>
}): ToolDefinition =>
defineTool({
approvalCategory,
...config,
})
}
export async function executeTool(
tool: ToolDefinition,
args: unknown,
@@ -100,34 +72,6 @@ export async function executeTool(
return response.toResult()
}
if (ctx.aclRules?.length) {
const { checkAcl } = await import('./acl/acl-guard')
const check = await checkAcl(
tool.name,
args as Record<string, unknown>,
ctx.browser,
ctx.aclRules,
)
if (check.blocked) {
const desc =
check.rule?.description ??
check.rule?.textMatch ??
check.rule?.sitePattern ??
'ACL rule'
if (check.pageId !== undefined && check.elementId !== undefined) {
await ctx.browser.highlightBlockedElement(
check.pageId,
check.elementId,
desc,
)
}
response.error(
`Action blocked by ACL rule: "${desc}". The element on this page is restricted. Choose a different action or skip this step.`,
)
return response.toResult()
}
}
try {
await tool.handler(args, ctx, response)
} catch (err) {
@@ -137,6 +81,7 @@ export async function executeTool(
const result = await response.build(ctx.browser)
// TODO: nikhil -- maybe add to tool context instead of ugly args casting
const pageId = (args as Record<string, unknown>).page
if (typeof pageId === 'number') {
const tabId = ctx.browser.getTabIdForPage(pageId)

Some files were not shown because too many files have changed in this diff Show More