mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
2 Commits
polecat/fl
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be59dccdd | ||
|
|
dad2331448 |
@@ -1,40 +0,0 @@
|
||||
import { cloudSyncSignInLinks } from '@/lib/constants/productUrls'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CloudSyncDisclosureProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CloudSyncDisclosure({ className }: CloudSyncDisclosureProps) {
|
||||
const [termsLink, privacyLink, cloudSyncLink] = cloudSyncSignInLinks
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'text-center text-muted-foreground text-xs leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
By signing in, you agree to the <DisclosureLink link={termsLink} /> and
|
||||
acknowledge the <DisclosureLink link={privacyLink} />.{' '}
|
||||
<DisclosureLink link={cloudSyncLink} />.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function DisclosureLink({
|
||||
link,
|
||||
}: {
|
||||
link: (typeof cloudSyncSignInLinks)[number]
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -80,11 +80,6 @@ const primarySettingsSections: NavSection[] = [
|
||||
icon: Palette,
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Reset Data',
|
||||
to: '/settings/reset-data',
|
||||
icon: RotateCcw,
|
||||
},
|
||||
{
|
||||
name: 'Tool Approvals',
|
||||
to: '/settings/approvals',
|
||||
|
||||
@@ -30,7 +30,6 @@ import { MagicLinkCallback } from './login/MagicLinkCallback'
|
||||
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
|
||||
import { MemoryPage } from './memory/MemoryPage'
|
||||
import { ProfilePage } from './profile/ProfilePage'
|
||||
import { ResetDataPage } from './reset-data/ResetDataPage'
|
||||
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
@@ -144,7 +143,6 @@ export const App: FC = () => {
|
||||
<Route path="chat" element={<LlmHubPage />} />
|
||||
<Route path="mcp" element={<MCPSettingsPage />} />
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="reset-data" element={<ResetDataPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
|
||||
@@ -131,15 +131,14 @@ export interface CreateHarnessAgentInput {
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
/**
|
||||
* Hermes-only — provider id from `HERMES_SUPPORTED_PROVIDERS`. When
|
||||
* paired with `apiKey`, the backend writes a per-agent
|
||||
* config.yaml + .env into the agent's HERMES_HOME so the first chat
|
||||
* doesn't depend on the user having run `hermes setup` globally.
|
||||
* Adapter provider id from the user's BrowserOS AI Settings entry.
|
||||
* Provider-backed adapters use this with `apiKey`/`baseUrl` to write
|
||||
* or provision their runtime-specific provider config.
|
||||
*/
|
||||
providerType?: string
|
||||
/** Hermes-only — API key paired with `providerType`. */
|
||||
/** API key paired with `providerType` when the selected adapter needs one. */
|
||||
apiKey?: string
|
||||
/** Hermes-only — base URL for the `custom` provider. */
|
||||
/** Base URL for OpenAI-compatible/custom provider entries. */
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { CloudSyncDisclosure } from '@/components/auth/CloudSyncDisclosure'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -200,8 +199,6 @@ export const LoginPage: FC = () => {
|
||||
)}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<CloudSyncDisclosure />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export const MEMORY_QUERY_KEY = 'memory'
|
||||
|
||||
async function fetchMemory(baseUrl: string): Promise<string> {
|
||||
const response = await fetch(`${baseUrl}/memory`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
@@ -32,7 +30,7 @@ export function useMemoryContent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<string, Error>({
|
||||
queryKey: [MEMORY_QUERY_KEY, baseUrl],
|
||||
queryKey: ['memory', baseUrl],
|
||||
queryFn: () => fetchMemory(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
@@ -40,7 +38,7 @@ export function useMemoryContent() {
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => saveMemory(baseUrl as string, content),
|
||||
onSuccess: (_data, content) => {
|
||||
queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], content)
|
||||
queryClient.setQueryData(['memory', baseUrl], content)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Brain, FileText, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { type FC, type ReactNode, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { MEMORY_QUERY_KEY } from '../memory/useMemoryContent'
|
||||
import { SOUL_QUERY_KEY } from '../soul/useSoulContent'
|
||||
|
||||
type ResetTarget = 'memory' | 'soul'
|
||||
|
||||
type ResetAction = {
|
||||
target: ResetTarget
|
||||
title: string
|
||||
description: string
|
||||
buttonLabel: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
async function deleteServerResource(
|
||||
baseUrl: string,
|
||||
resource: ResetTarget,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/${resource}`, { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
export const ResetDataPage: FC = () => {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: isUrlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingAction, setPendingAction] = useState<ResetAction | null>(null)
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (target: ResetTarget) => {
|
||||
if (!baseUrl) throw new Error('BrowserOS server URL is unavailable')
|
||||
await deleteServerResource(baseUrl, target)
|
||||
return target
|
||||
},
|
||||
onSuccess: async (target) => {
|
||||
if (target === 'memory') {
|
||||
queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], '')
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: target === 'memory' ? [MEMORY_QUERY_KEY] : [SOUL_QUERY_KEY],
|
||||
})
|
||||
toast.success(target === 'memory' ? 'Memory reset' : 'SOUL.md reset')
|
||||
},
|
||||
onError: (_error, target) => {
|
||||
toast.error(
|
||||
target === 'memory'
|
||||
? 'Failed to reset memory'
|
||||
: 'Failed to reset SOUL.md',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const actions: ResetAction[] = [
|
||||
{
|
||||
target: 'memory',
|
||||
title: 'Reset memory?',
|
||||
description:
|
||||
'This deletes CORE.md and daily memory files. This cannot be undone.',
|
||||
buttonLabel: 'Reset memory',
|
||||
icon: <Brain className="h-4 w-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
target: 'soul',
|
||||
title: 'Reset SOUL.md?',
|
||||
description:
|
||||
'This replaces SOUL.md with the default template. This cannot be undone.',
|
||||
buttonLabel: 'Reset SOUL.md',
|
||||
icon: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
},
|
||||
]
|
||||
|
||||
const isBusy = isUrlLoading || resetMutation.isPending
|
||||
const disabled = isBusy || Boolean(urlError) || !baseUrl
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pendingAction) return
|
||||
resetMutation.mutate(pendingAction.target)
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="font-medium text-xs uppercase tracking-wider">
|
||||
Reset
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-semibold text-2xl">Reset Data</h1>
|
||||
</div>
|
||||
|
||||
{urlError ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
|
||||
<p className="text-destructive text-sm">
|
||||
BrowserOS server is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.target}
|
||||
className="flex flex-col gap-3 rounded-lg border bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{action.icon}
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-medium text-sm">{action.buttonLabel}</h2>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => setPendingAction(action)}
|
||||
>
|
||||
{resetMutation.isPending &&
|
||||
resetMutation.variables === action.target ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{action.buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(pendingAction)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingAction(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{pendingAction?.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingAction?.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{pendingAction?.buttonLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AlertCircle, CheckCircle2, Loader2, Mail } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { CloudSyncDisclosure } from '@/components/auth/CloudSyncDisclosure'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -200,8 +199,6 @@ export const StepTwo = ({ direction, onContinue }: StepTwoProps) => {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<CloudSyncDisclosure />
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -2,8 +2,7 @@ import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo } from 'react'
|
||||
import { useSessionInfo } from '@/lib/auth/sessionStorage'
|
||||
import { useConversations } from '@/lib/conversations/conversationStorage'
|
||||
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
|
||||
@@ -22,11 +21,8 @@ import {
|
||||
import { LocalChatHistory } from './local/LocalChatHistory'
|
||||
|
||||
const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
const { conversationId: activeConversationId, resetConversation } =
|
||||
useChatSessionContext()
|
||||
const { clearConversations } = useConversations()
|
||||
const { conversationId: activeConversationId } = useChatSessionContext()
|
||||
const queryClient = useQueryClient()
|
||||
const [isClearingAll, setIsClearingAll] = useState(false)
|
||||
|
||||
const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, {
|
||||
userId,
|
||||
@@ -72,50 +68,6 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
deleteConversationMutation.mutate({ rowId: id })
|
||||
}
|
||||
|
||||
const getAllRemoteConversationIds = async () => {
|
||||
let pages = graphqlData?.pages ?? []
|
||||
let hasMore = Boolean(
|
||||
pages.at(-1)?.conversations?.pageInfo.hasNextPage ?? hasNextPage,
|
||||
)
|
||||
|
||||
while (hasMore) {
|
||||
const result = await fetchNextPage()
|
||||
pages = result.data?.pages ?? pages
|
||||
hasMore = Boolean(pages.at(-1)?.conversations?.pageInfo.hasNextPage)
|
||||
}
|
||||
|
||||
return pages.flatMap((page) =>
|
||||
(page.conversations?.nodes ?? [])
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||
.map((node) => node.rowId),
|
||||
)
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
setIsClearingAll(true)
|
||||
try {
|
||||
const ids = [...new Set(await getAllRemoteConversationIds())]
|
||||
for (let i = 0; i < ids.length; i += 10) {
|
||||
const batch = ids.slice(i, i + 10)
|
||||
await Promise.all(
|
||||
batch.map((rowId) =>
|
||||
deleteConversationMutation.mutateAsync({ rowId }),
|
||||
),
|
||||
)
|
||||
}
|
||||
await clearConversations()
|
||||
resetConversation()
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [getQueryKeyFromDocument(GetConversationsForHistoryDocument)],
|
||||
})
|
||||
toast.success('Chat sessions cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear chat sessions')
|
||||
} finally {
|
||||
setIsClearingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
const conversations = useMemo<HistoryConversation[]>(() => {
|
||||
if (!graphqlData?.pages) return []
|
||||
|
||||
@@ -158,8 +110,6 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
groupedConversations={groupedConversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onDelete={handleDelete}
|
||||
onClearAll={handleClearAll}
|
||||
isClearingAll={isClearingAll || deleteConversationMutation.isPending}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
@@ -171,6 +121,8 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
export const ChatHistory: FC = () => {
|
||||
const { sessionInfo } = useSessionInfo()
|
||||
const userId = sessionInfo.user?.id
|
||||
// needed to initiate remote-sync
|
||||
useConversations()
|
||||
|
||||
if (userId) {
|
||||
return <RemoteChatHistory userId={userId} />
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { Loader2, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, MessageSquare } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ConversationGroup } from './ConversationGroup'
|
||||
import type { GroupedConversations } from './types'
|
||||
import { TIME_GROUP_LABELS } from './utils'
|
||||
@@ -23,8 +13,6 @@ interface ConversationListProps {
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
isRefreshing?: boolean
|
||||
onClearAll?: () => void
|
||||
isClearingAll?: boolean
|
||||
}
|
||||
|
||||
export const ConversationList: FC<ConversationListProps> = ({
|
||||
@@ -35,11 +23,8 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isRefreshing,
|
||||
onClearAll,
|
||||
isClearingAll,
|
||||
}) => {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
const [showClearAllDialog, setShowClearAllDialog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || !onLoadMore) return
|
||||
@@ -71,118 +56,65 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
groupedConversations.thisMonth.length > 0 ||
|
||||
groupedConversations.older.length > 0
|
||||
|
||||
const handleConfirmClearAll = () => {
|
||||
onClearAll?.()
|
||||
setShowClearAllDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
|
||||
<div className="w-full p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<h2 className="font-semibold text-sm">Chat history</h2>
|
||||
{onClearAll && hasConversations && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClearAllDialog(true)}
|
||||
disabled={isClearingAll}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||
title="Clear sessions"
|
||||
>
|
||||
{isClearingAll ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Clear sessions
|
||||
</button>
|
||||
)}
|
||||
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
|
||||
<div className="w-full p-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center justify-center gap-2 pb-3 text-muted-foreground text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Fetching latest conversations</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasConversations ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No conversations yet
|
||||
</p>
|
||||
<Link to="/" className="mt-2 text-primary text-sm hover:underline">
|
||||
Start a new chat
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.today}
|
||||
conversations={groupedConversations.today}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisWeek}
|
||||
conversations={groupedConversations.thisWeek}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisMonth}
|
||||
conversations={groupedConversations.thisMonth}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.older}
|
||||
conversations={groupedConversations.older}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center justify-center gap-2 pb-3 text-muted-foreground text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Fetching latest conversations</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasConversations ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No conversations yet
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-2 text-primary text-sm hover:underline"
|
||||
{hasNextPage && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="flex items-center justify-center py-4"
|
||||
>
|
||||
Start a new chat
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.today}
|
||||
conversations={groupedConversations.today}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisWeek}
|
||||
conversations={groupedConversations.thisWeek}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisMonth}
|
||||
conversations={groupedConversations.thisMonth}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.older}
|
||||
conversations={groupedConversations.older}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
|
||||
{hasNextPage && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="flex items-center justify-center py-4"
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AlertDialog
|
||||
open={showClearAllDialog}
|
||||
onOpenChange={setShowClearAllDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all sessions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action permanently deletes every chat session in history.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmClearAll}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Clear sessions
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useConversations } from '@/lib/conversations/conversationStorage'
|
||||
import { useChatSessionContext } from '../../layout/ChatSessionContext'
|
||||
import { ConversationList } from '../components/ConversationList'
|
||||
@@ -8,13 +7,9 @@ import type { HistoryConversation } from '../components/types'
|
||||
import { extractLastUserMessage, groupConversations } from '../components/utils'
|
||||
|
||||
export const LocalChatHistory: FC = () => {
|
||||
const {
|
||||
conversations: localConversations,
|
||||
removeConversation,
|
||||
clearConversations,
|
||||
} = useConversations()
|
||||
const { conversationId: activeConversationId, resetConversation } =
|
||||
useChatSessionContext()
|
||||
const { conversations: localConversations, removeConversation } =
|
||||
useConversations()
|
||||
const { conversationId: activeConversationId } = useChatSessionContext()
|
||||
|
||||
const conversations = useMemo<HistoryConversation[]>(() => {
|
||||
return localConversations.map((conv) => ({
|
||||
@@ -29,22 +24,11 @@ export const LocalChatHistory: FC = () => {
|
||||
[conversations],
|
||||
)
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await clearConversations()
|
||||
resetConversation()
|
||||
toast.success('Chat sessions cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear chat sessions')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationList
|
||||
groupedConversations={groupedConversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onDelete={removeConversation}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,27 +10,7 @@ function restoreGlobal(name: string, value: unknown) {
|
||||
}
|
||||
|
||||
describe('stageAttachment', () => {
|
||||
it('stages pasted clipboard images that do not have a filename', async () => {
|
||||
const file = new File([new Uint8Array([1, 2, 3])], '', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
const result = await stageAttachment(file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
expect(result.attachment.kind).toBe('image')
|
||||
expect(result.attachment.name).toBe('image')
|
||||
expect(result.attachment.mediaType).toBe('image/png')
|
||||
expect(result.attachment.dataUrl).toStartWith('data:image/png;base64,')
|
||||
expect(result.attachment.payload).toMatchObject({
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
dataUrl: result.attachment.dataUrl,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the recompressed blob media type for large pasted images', async () => {
|
||||
it('uses the recompressed blob media type for large images', async () => {
|
||||
const originalCreateImageBitmap = Reflect.get(
|
||||
globalThis,
|
||||
'createImageBitmap',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
cloudSyncHelpUrl,
|
||||
cloudSyncSignInLinks,
|
||||
privacyPolicyUrl,
|
||||
termsOfServiceUrl,
|
||||
} from './productUrls'
|
||||
|
||||
describe('cloud sync sign-in links', () => {
|
||||
it('points to the public legal and cloud sync documentation URLs', () => {
|
||||
expect(termsOfServiceUrl).toBe('https://browseros.com/terms')
|
||||
expect(privacyPolicyUrl).toBe('https://browseros.com/privacy')
|
||||
expect(cloudSyncHelpUrl).toBe(
|
||||
'https://docs.browseros.com/features/sync-to-cloud',
|
||||
)
|
||||
})
|
||||
|
||||
it('includes legal and cloud sync documentation links in display order', () => {
|
||||
expect(cloudSyncSignInLinks).toEqual([
|
||||
{ label: 'Terms of Service', url: termsOfServiceUrl },
|
||||
{ label: 'Privacy Policy', url: privacyPolicyUrl },
|
||||
{ label: 'Learn more about cloud sync', url: cloudSyncHelpUrl },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -23,26 +23,6 @@ export const githubOrgUrl = 'https://github.com/browseros-ai'
|
||||
*/
|
||||
export const privacyPolicyUrl = 'https://browseros.com/privacy'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const termsOfServiceUrl = 'https://browseros.com/terms'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const cloudSyncHelpUrl =
|
||||
'https://docs.browseros.com/features/sync-to-cloud'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const cloudSyncSignInLinks = [
|
||||
{ label: 'Terms of Service', url: termsOfServiceUrl },
|
||||
{ label: 'Privacy Policy', url: privacyPolicyUrl },
|
||||
{ label: 'Learn more about cloud sync', url: cloudSyncHelpUrl },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,7 @@ import { storage } from '@wxt-dev/storage'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSessionInfo } from '../auth/sessionStorage'
|
||||
import {
|
||||
clearConversationExecutionHistory,
|
||||
removeConversationExecutionHistory,
|
||||
} from '../execution-history/storage'
|
||||
import { removeConversationExecutionHistory } from '../execution-history/storage'
|
||||
import { uploadConversationsToGraphql } from './uploadConversationsToGraphql'
|
||||
|
||||
const MAX_CONVERSATIONS = 50
|
||||
@@ -49,11 +46,6 @@ export function useConversations() {
|
||||
await removeConversationExecutionHistory(id)
|
||||
}
|
||||
|
||||
const clearConversations = async () => {
|
||||
await conversationStorage.setValue([])
|
||||
await clearConversationExecutionHistory()
|
||||
}
|
||||
|
||||
const saveConversation = async (id: string, messages: UIMessage[]) => {
|
||||
const current = (await conversationStorage.getValue()) ?? []
|
||||
const existingIndex = current.findIndex((c) => c.id === id)
|
||||
@@ -98,7 +90,6 @@ export function useConversations() {
|
||||
return {
|
||||
conversations,
|
||||
removeConversation,
|
||||
clearConversations,
|
||||
saveConversation,
|
||||
getConversation,
|
||||
}
|
||||
|
||||
@@ -82,10 +82,6 @@ export async function removeConversationExecutionHistory(
|
||||
await executionHistoryStorage.setValue(rest)
|
||||
}
|
||||
|
||||
export async function clearConversationExecutionHistory(): Promise<void> {
|
||||
await executionHistoryStorage.setValue({})
|
||||
}
|
||||
|
||||
export async function removeConversationExecutionTask(args: {
|
||||
conversationId: string
|
||||
taskId: string
|
||||
|
||||
@@ -9,7 +9,6 @@ import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { createBrowserOSFetch } from '../lib/browseros-fetch'
|
||||
import { createGeminiComputerUseFetch } from '../lib/clients/llm/gemini-computer-use-fetch'
|
||||
import {
|
||||
createMockBrowserOSLanguageModel,
|
||||
shouldUseMockBrowserOSLLM,
|
||||
@@ -42,12 +41,7 @@ function createGoogleFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.apiKey) throw new Error('Google provider requires apiKey')
|
||||
const fetch = createGeminiComputerUseFetch(config.model)
|
||||
return createGoogleGenerativeAI({
|
||||
apiKey: config.apiKey,
|
||||
...(config.baseUrl && { baseURL: config.baseUrl }),
|
||||
...(fetch && { fetch }),
|
||||
})
|
||||
return createGoogleGenerativeAI({ apiKey: config.apiKey })
|
||||
}
|
||||
|
||||
function createOpenRouterFactory(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
@@ -26,9 +26,4 @@ export function createMemoryRoutes() {
|
||||
await Bun.write(getCoreMemoryPath(), content)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.delete('/', async (c) => {
|
||||
await rm(getMemoryDir(), { recursive: true, force: true })
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { readSoul, resetSoulTemplate, writeSoul } from '../../lib/soul'
|
||||
import { readSoul, writeSoul } from '../../lib/soul'
|
||||
|
||||
const WriteSoulSchema = z.object({
|
||||
content: z.string(),
|
||||
@@ -18,8 +18,4 @@ export function createSoulRoutes() {
|
||||
const result = await writeSoul(content)
|
||||
return c.json(result)
|
||||
})
|
||||
.delete('/', async (c) => {
|
||||
const result = await resetSoulTemplate()
|
||||
return c.json(result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ import type {
|
||||
} from '../../../lib/agents/agent-store'
|
||||
import type { AgentDefinition } from '../../../lib/agents/agent-types'
|
||||
import { DbAgentStore } from '../../../lib/agents/db-agent-store'
|
||||
import { writeHermesPerAgentProvider } from '../../../lib/agents/hermes/hermes-paths'
|
||||
import { getHermesProviderMapping } from '../../../lib/agents/hermes/hermes-provider-map'
|
||||
import {
|
||||
FileMessageQueue,
|
||||
type QueuedMessage,
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
|
||||
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
|
||||
|
||||
export {
|
||||
MessageQueueFullError,
|
||||
@@ -33,14 +33,14 @@ export {
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRowSnapshot,
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { getBrowserosDir, getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
buildFilePreview,
|
||||
@@ -198,16 +198,11 @@ export type TurnLifecycleListener = (
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: AgentStore
|
||||
private readonly runtime: AgentRuntime
|
||||
private readonly browserosDir: string
|
||||
private readonly openclawProvisioner: OpenClawProvisioner | null
|
||||
private readonly turnRegistry: TurnRegistry
|
||||
private readonly messageQueue: FileMessageQueue
|
||||
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
|
||||
/**
|
||||
* Optional override for the BrowserOS dir used by Hermes per-agent
|
||||
* provider config writes. Defaults to the global `getBrowserosDir()`
|
||||
* lookup at write time when undefined; tests can inject a tmp dir.
|
||||
*/
|
||||
private readonly browserosDir: string | undefined
|
||||
/**
|
||||
* Lazy-initialised so tests that swap in a fake `agentStore` don't
|
||||
* eagerly hit `getDb()` (which throws when the test harness hasn't
|
||||
@@ -230,8 +225,8 @@ export class AgentHarnessService {
|
||||
deps: {
|
||||
agentStore?: AgentStore
|
||||
runtime?: AgentRuntime
|
||||
browserosServerPort?: number
|
||||
browserosDir?: string
|
||||
browserosServerPort?: number
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
turnRegistry?: TurnRegistry
|
||||
@@ -239,17 +234,27 @@ export class AgentHarnessService {
|
||||
producedFilesStore?: ProducedFilesStore
|
||||
} = {},
|
||||
) {
|
||||
this.browserosDir = deps.browserosDir ?? getBrowserosDir()
|
||||
this.agentStore = deps.agentStore ?? new DbAgentStore()
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new AcpxRuntime({
|
||||
browserosDir: this.browserosDir,
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
})
|
||||
this.openclawProvisioner = deps.openclawProvisioner ?? null
|
||||
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
|
||||
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
|
||||
this.browserosDir = deps.browserosDir
|
||||
this.messageQueue =
|
||||
deps.messageQueue ??
|
||||
new FileMessageQueue({
|
||||
filePath: join(
|
||||
this.browserosDir,
|
||||
'agents',
|
||||
'harness',
|
||||
'message-queues.json',
|
||||
),
|
||||
})
|
||||
if (deps.producedFilesStore) {
|
||||
this.explicitProducedFilesStore = deps.producedFilesStore
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { constants, type Stats } from 'node:fs'
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
} from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { ensureDirectory } from '../ensure-directory'
|
||||
import {
|
||||
MEMORY_TEMPLATE,
|
||||
RUNTIME_SKILLS,
|
||||
@@ -66,7 +66,7 @@ export function resolveAgentRuntimePaths(input: {
|
||||
|
||||
/** Seeds the stable per-agent identity and memory home without overwriting edits. */
|
||||
export async function ensureAgentHome(paths: AgentRuntimePaths): Promise<void> {
|
||||
await ensureDirectory(join(paths.agentHome, 'memory'))
|
||||
await mkdir(join(paths.agentHome, 'memory'), { recursive: true })
|
||||
await writeFileIfMissing(join(paths.agentHome, 'SOUL.md'), SOUL_TEMPLATE)
|
||||
await writeFileIfMissing(join(paths.agentHome, 'MEMORY.md'), MEMORY_TEMPLATE)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export async function materializeCodexHome(input: {
|
||||
skillNames: string[]
|
||||
sourceCodexHome?: string
|
||||
}): Promise<void> {
|
||||
await ensureDirectory(input.paths.codexHome)
|
||||
await mkdir(input.paths.codexHome, { recursive: true })
|
||||
const source =
|
||||
input.sourceCodexHome ??
|
||||
process.env.CODEX_HOME?.trim() ??
|
||||
@@ -163,7 +163,7 @@ export async function ensureUsableCwd(
|
||||
isDefaultWorkspace: boolean,
|
||||
): Promise<void> {
|
||||
if (isDefaultWorkspace) {
|
||||
await ensureDirectory(cwd)
|
||||
await mkdir(cwd, { recursive: true })
|
||||
return
|
||||
}
|
||||
let info: Stats
|
||||
@@ -195,7 +195,7 @@ async function writeFileIfMissing(
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
await ensureDirectory(dirname(path))
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
try {
|
||||
await writeFile(path, content, { encoding: 'utf8', flag: 'wx' })
|
||||
} catch (err) {
|
||||
@@ -205,7 +205,7 @@ async function writeFileIfMissing(
|
||||
|
||||
async function symlinkIfPresent(source: string, target: string): Promise<void> {
|
||||
if (!(await sourceFileExists(source))) return
|
||||
await ensureDirectory(dirname(target))
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
try {
|
||||
await symlink(source, target)
|
||||
} catch (err) {
|
||||
@@ -216,7 +216,7 @@ async function symlinkIfPresent(source: string, target: string): Promise<void> {
|
||||
async function copyIfPresent(source: string, target: string): Promise<void> {
|
||||
if (!(await sourceFileExists(source))) return
|
||||
const content = await readFile(source, 'utf8')
|
||||
await ensureDirectory(dirname(target))
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
try {
|
||||
await writeFile(target, content, { encoding: 'utf8', flag: 'wx' })
|
||||
} catch (err) {
|
||||
@@ -226,7 +226,7 @@ async function copyIfPresent(source: string, target: string): Promise<void> {
|
||||
|
||||
/** Writes generated content via atomic replace so readers never see partial files. */
|
||||
async function writeFileAtomic(path: string, content: string): Promise<void> {
|
||||
await ensureDirectory(dirname(path))
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const temporaryPath = join(
|
||||
dirname(path),
|
||||
`.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import {
|
||||
type AcpRuntimeEvent,
|
||||
@@ -32,6 +31,10 @@ import type {
|
||||
AgentHistoryEntry,
|
||||
AgentHistoryToolCall,
|
||||
} from './agent-types'
|
||||
import {
|
||||
type OpenclawGatewayAccessor,
|
||||
resolveOpenclawAcpCommand,
|
||||
} from './openclaw/acp-command'
|
||||
import { getHermesRuntime } from './runtime'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
@@ -43,24 +46,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Live-getter access to the OpenClaw gateway runtime info. Required
|
||||
* when spawning the openclaw ACP adapter inside the gateway container.
|
||||
*
|
||||
* Fields are getters (not snapshot values) so the harness picks up the
|
||||
* current VM/container paths at spawn time. The bundled gateway runs
|
||||
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
|
||||
*/
|
||||
export interface OpenclawGatewayAccessor {
|
||||
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
|
||||
getContainerName(): string
|
||||
/** LIMA_HOME directory containing the browseros-vm instance. */
|
||||
getLimaHomeDir(): string
|
||||
/** Resolved path to the `limactl` binary (bundled or host). */
|
||||
getLimactlPath(): string
|
||||
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
|
||||
getVmName(): string
|
||||
}
|
||||
export type { OpenclawGatewayAccessor } from './openclaw/acp-command'
|
||||
|
||||
type AcpxRuntimeOptions = {
|
||||
cwd?: string
|
||||
@@ -736,79 +722,6 @@ function createBrowserosAgentRegistry(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the command string acpx will spawn for an `openclaw` adapter.
|
||||
* Runs `openclaw acp` inside the gateway container via the bundled
|
||||
* `limactl shell <vm> -- nerdctl exec -i ...` chain so the binary
|
||||
* already installed alongside the gateway is reused; BrowserOS does
|
||||
* not require a host-side openclaw install.
|
||||
*
|
||||
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
|
||||
* so no gateway token flag is needed for the local ACP bridge.
|
||||
*
|
||||
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
|
||||
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
|
||||
* the ACP message stream.
|
||||
*/
|
||||
function resolveOpenclawAcpCommand(
|
||||
gateway: OpenclawGatewayAccessor,
|
||||
sessionKey: string | null,
|
||||
): string {
|
||||
const limactl = gateway.getLimactlPath()
|
||||
const vm = gateway.getVmName()
|
||||
const container = gateway.getContainerName()
|
||||
const limaHome = gateway.getLimaHomeDir()
|
||||
const gatewayUrlInsideContainer = `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`
|
||||
|
||||
// `--session <key>` routes the bridge's newSession requests to the
|
||||
// matching gateway agent. acpx does not pass sessionKey through ACP
|
||||
// newSession params, so without this CLI flag the bridge falls back
|
||||
// to a synthetic acp:<uuid> session that does not resolve to any
|
||||
// provisioned gateway agent.
|
||||
//
|
||||
// Harness keys are `agent:<harness-id>:main`; the harness id matches
|
||||
// a dual-created gateway agent name, so the bridge resolves directly.
|
||||
// Any legacy non-agent key falls back to the always-provisioned
|
||||
// `main` gateway agent with the original key encoded as a channel
|
||||
// suffix.
|
||||
const bridgeSessionKey = sessionKey
|
||||
? sessionKey.startsWith('agent:')
|
||||
? sessionKey
|
||||
: `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
|
||||
: null
|
||||
//
|
||||
// Prefix `env LIMA_HOME=<path>` so the spawned limactl finds the
|
||||
// BrowserOS-owned VM instance. The BrowserOS server doesn't set
|
||||
// LIMA_HOME on its own process env (it injects per-spawn elsewhere),
|
||||
// so the acpx-spawned subprocess won't inherit it without this hint.
|
||||
const argv = [
|
||||
'env',
|
||||
`LIMA_HOME=${limaHome}`,
|
||||
limactl,
|
||||
'shell',
|
||||
'--workdir',
|
||||
'/',
|
||||
vm,
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-i',
|
||||
'-e',
|
||||
'OPENCLAW_HIDE_BANNER=1',
|
||||
'-e',
|
||||
'OPENCLAW_SUPPRESS_NOTES=1',
|
||||
container,
|
||||
'openclaw',
|
||||
'acp',
|
||||
'--url',
|
||||
gatewayUrlInsideContainer,
|
||||
]
|
||||
if (bridgeSessionKey) {
|
||||
argv.push('--session', bridgeSessionKey)
|
||||
}
|
||||
return argv.join(' ')
|
||||
}
|
||||
|
||||
async function applyRuntimeControls(
|
||||
runtime: AcpxCoreRuntime,
|
||||
handle: AcpRuntimeHandle,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getVmStateDir } from '../../../lib/browseros-dir'
|
||||
import { getVmStateDir } from '../../browseros-dir'
|
||||
|
||||
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
|
||||
export function getHermesHostStateDir(browserosDir?: string): string {
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
|
||||
|
||||
/**
|
||||
* Live-getter access to the OpenClaw gateway runtime info. Required
|
||||
* when spawning the OpenClaw ACP adapter inside the gateway container.
|
||||
*
|
||||
* Fields are getters (not snapshot values) so the harness picks up the
|
||||
* current VM/container paths at spawn time. The bundled gateway runs
|
||||
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
|
||||
*/
|
||||
export interface OpenclawGatewayAccessor {
|
||||
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
|
||||
getContainerName(): string
|
||||
/** LIMA_HOME directory containing the browseros-vm instance. */
|
||||
getLimaHomeDir(): string
|
||||
/** Resolved path to the `limactl` binary (bundled or host). */
|
||||
getLimactlPath(): string
|
||||
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
|
||||
getVmName(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the command string acpx will spawn for an `openclaw` adapter.
|
||||
* Runs `openclaw acp` inside the gateway container via the bundled
|
||||
* `limactl shell <vm> -- nerdctl exec -i ...` chain so the binary
|
||||
* already installed alongside the gateway is reused; BrowserOS does
|
||||
* not require a host-side OpenClaw install.
|
||||
*
|
||||
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
|
||||
* so no gateway token flag is needed for the local ACP bridge.
|
||||
*
|
||||
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
|
||||
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
|
||||
* the ACP message stream.
|
||||
*/
|
||||
export function resolveOpenclawAcpCommand(
|
||||
gateway: OpenclawGatewayAccessor,
|
||||
sessionKey: string | null,
|
||||
): string {
|
||||
const limactl = gateway.getLimactlPath()
|
||||
const vm = gateway.getVmName()
|
||||
const container = gateway.getContainerName()
|
||||
const limaHome = gateway.getLimaHomeDir()
|
||||
const gatewayUrlInsideContainer = `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`
|
||||
|
||||
// `--session <key>` routes the bridge's newSession requests to the
|
||||
// matching gateway agent. acpx does not pass sessionKey through ACP
|
||||
// newSession params, so without this CLI flag the bridge falls back
|
||||
// to a synthetic acp:<uuid> session that does not resolve to any
|
||||
// provisioned gateway agent.
|
||||
//
|
||||
// Harness keys are `agent:<harness-id>:main`; the harness id matches
|
||||
// a dual-created gateway agent name, so the bridge resolves directly.
|
||||
// Any legacy non-agent key falls back to the always-provisioned
|
||||
// `main` gateway agent with the original key encoded as a channel
|
||||
// suffix.
|
||||
const bridgeSessionKey = sessionKey
|
||||
? sessionKey.startsWith('agent:')
|
||||
? sessionKey
|
||||
: `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
|
||||
: null
|
||||
|
||||
// Prefix `env LIMA_HOME=<path>` so the spawned limactl finds the
|
||||
// BrowserOS-owned VM instance. The BrowserOS server doesn't set
|
||||
// LIMA_HOME on its own process env (it injects per-spawn elsewhere),
|
||||
// so the acpx-spawned subprocess won't inherit it without this hint.
|
||||
const argv = [
|
||||
'env',
|
||||
`LIMA_HOME=${limaHome}`,
|
||||
limactl,
|
||||
'shell',
|
||||
'--workdir',
|
||||
'/',
|
||||
vm,
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-i',
|
||||
'-e',
|
||||
'OPENCLAW_HIDE_BANNER=1',
|
||||
'-e',
|
||||
'OPENCLAW_SUPPRESS_NOTES=1',
|
||||
container,
|
||||
'openclaw',
|
||||
'acp',
|
||||
'--url',
|
||||
gatewayUrlInsideContainer,
|
||||
]
|
||||
if (bridgeSessionKey) {
|
||||
argv.push('--session', bridgeSessionKey)
|
||||
}
|
||||
return argv.join(' ')
|
||||
}
|
||||
@@ -15,11 +15,6 @@ import {
|
||||
HERMES_CONTAINER_NAME,
|
||||
HERMES_IMAGE,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import {
|
||||
getHermesAgentHomeHostDir,
|
||||
getHermesHarnessHostDir,
|
||||
getHermesHostStateDir,
|
||||
} from '../../../api/services/hermes/hermes-paths'
|
||||
import { getBrowserosDir } from '../../browseros-dir'
|
||||
import { ContainerCli } from '../../container/container-cli'
|
||||
import { ImageLoader } from '../../container/image-loader'
|
||||
@@ -46,6 +41,11 @@ import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
import {
|
||||
getHermesAgentHomeHostDir,
|
||||
getHermesHarnessHostDir,
|
||||
getHermesHostStateDir,
|
||||
} from '../hermes/hermes-paths'
|
||||
import { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
import type { ExecSpec } from './types'
|
||||
@@ -53,8 +53,6 @@ import type { ExecSpec } from './types'
|
||||
const HERMES_BINARY = '/opt/hermes/.venv/bin/hermes'
|
||||
|
||||
export interface HermesContainerRuntimeConfig {
|
||||
/** BrowserOS state root — used to compute per-agent home paths. */
|
||||
browserosDir: string
|
||||
/** Host-side directory where Hermes per-agent home dirs live. */
|
||||
hermesHarnessHostDir: string
|
||||
}
|
||||
@@ -150,10 +148,7 @@ export class HermesContainerRuntime extends ContainerAgentRuntime {
|
||||
// ── AgentRuntime additions ───────────────────────────────────────
|
||||
|
||||
getPerAgentHomeDir(agentId: string): string {
|
||||
return getHermesAgentHomeHostDir({
|
||||
browserosDir: this.hermesConfig.browserosDir,
|
||||
agentId,
|
||||
})
|
||||
return join(this.hermesConfig.hermesHarnessHostDir, agentId, 'home')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,9 +186,8 @@ export class HermesContainerRuntime extends ContainerAgentRuntime {
|
||||
*/
|
||||
function translateHermesHomeToContainerPath(
|
||||
hostHome: string,
|
||||
browserosDir: string,
|
||||
harnessHostRoot: string,
|
||||
): string {
|
||||
const harnessHostRoot = getHermesHarnessHostDir(browserosDir)
|
||||
if (hostHome === harnessHostRoot) return HERMES_CONTAINER_HARNESS_DIR
|
||||
if (hostHome.startsWith(`${harnessHostRoot}/`)) {
|
||||
return `${HERMES_CONTAINER_HARNESS_DIR}${hostHome.slice(harnessHostRoot.length)}`
|
||||
@@ -232,7 +226,7 @@ export async function prepareHermesContext(
|
||||
|
||||
const hermesAgentHomeInContainer = translateHermesHomeToContainerPath(
|
||||
hermesAgentHome,
|
||||
input.browserosDir,
|
||||
getHermesHarnessHostDir(input.browserosDir),
|
||||
)
|
||||
|
||||
return finishBrowserosManagedContext({
|
||||
@@ -260,6 +254,16 @@ export interface ConfigureHermesRuntimeOptions {
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export type HermesRuntimeStartupPhase = 'configure' | 'install' | 'start'
|
||||
|
||||
export interface StartHermesRuntimeBestEffortOptions
|
||||
extends ConfigureHermesRuntimeOptions {
|
||||
configureRuntime?: (
|
||||
options: ConfigureHermesRuntimeOptions,
|
||||
) => HermesContainerRuntime | null
|
||||
onError?: (phase: HermesRuntimeStartupPhase, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `HermesContainerRuntime` with production deps (bundled
|
||||
* limactl, BrowserOS state dirs, Lima VM runtime) and register it in
|
||||
@@ -310,7 +314,7 @@ export function configureHermesRuntime(
|
||||
vmName: VM_NAME,
|
||||
lockDir: join(hermesStateDir, '.locks'),
|
||||
},
|
||||
{ browserosDir, hermesHarnessHostDir },
|
||||
{ hermesHarnessHostDir },
|
||||
)
|
||||
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
@@ -318,8 +322,55 @@ export function configureHermesRuntime(
|
||||
return runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup wiring for the Hermes adapter. Kept beside the adapter runtime so
|
||||
* the server entry point does not need to know Hermes' install/start sequence.
|
||||
*/
|
||||
export function startHermesRuntimeBestEffort(
|
||||
options: StartHermesRuntimeBestEffortOptions = {},
|
||||
): HermesContainerRuntime | null {
|
||||
const {
|
||||
configureRuntime = configureHermesRuntime,
|
||||
onError = logHermesStartupError,
|
||||
...configureOptions
|
||||
} = options
|
||||
|
||||
let runtime: HermesContainerRuntime | null
|
||||
try {
|
||||
runtime = configureRuntime(configureOptions)
|
||||
} catch (err) {
|
||||
onError('configure', err)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!runtime) return null
|
||||
|
||||
void runtime
|
||||
.executeAction({ type: 'install' })
|
||||
.catch((err) => onError('install', err))
|
||||
void runtime
|
||||
.executeAction({ type: 'start' })
|
||||
.catch((err) => onError('start', err))
|
||||
return runtime
|
||||
}
|
||||
|
||||
/** Convenience getter — returns the registered runtime or null. */
|
||||
export function getHermesRuntime(): HermesContainerRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('hermes')
|
||||
return r instanceof HermesContainerRuntime ? r : null
|
||||
}
|
||||
|
||||
function logHermesStartupError(
|
||||
phase: HermesRuntimeStartupPhase,
|
||||
error: unknown,
|
||||
): void {
|
||||
const message =
|
||||
phase === 'configure'
|
||||
? 'Hermes container configuration failed, continuing without it'
|
||||
: phase === 'install'
|
||||
? 'Hermes prewarm failed'
|
||||
: 'Hermes container start failed'
|
||||
logger.warn(message, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export {
|
||||
HermesContainerRuntime,
|
||||
type HermesContainerRuntimeConfig,
|
||||
prepareHermesContext,
|
||||
type StartHermesRuntimeBestEffortOptions,
|
||||
startHermesRuntimeBestEffort,
|
||||
} from './hermes-container-runtime'
|
||||
export {
|
||||
HostProcessAgentRuntime,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { unlinkSync } from 'node:fs'
|
||||
import { readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
|
||||
import { ensureDirectory } from './ensure-directory'
|
||||
import { logger } from './logger'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
@@ -113,12 +112,12 @@ export function removeServerConfigSync(): void {
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
logDevelopmentBrowserosDir()
|
||||
await ensureDirectory(getMemoryDir())
|
||||
await ensureDirectory(getSkillsDir())
|
||||
await ensureDirectory(getBuiltinSkillsDir())
|
||||
await ensureDirectory(getSessionsDir())
|
||||
await ensureDirectory(getLazyMonitoringRunsDir())
|
||||
await ensureDirectory(getVmDisksDir())
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getVmDisksDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
const GEMINI_COMPUTER_USE_MODEL_PATTERN = /computer-use/i
|
||||
|
||||
const GEMINI_COMPUTER_USE_TOOL = {
|
||||
computerUse: {
|
||||
environment: 'ENVIRONMENT_BROWSER',
|
||||
},
|
||||
} as const
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function hasComputerUseTool(tool: unknown): boolean {
|
||||
return isJsonObject(tool) && 'computerUse' in tool
|
||||
}
|
||||
|
||||
export function isGeminiComputerUseModel(modelId: string): boolean {
|
||||
return GEMINI_COMPUTER_USE_MODEL_PATTERN.test(modelId)
|
||||
}
|
||||
|
||||
export function addGeminiComputerUseTool(body: unknown): unknown {
|
||||
if (!isJsonObject(body)) return body
|
||||
|
||||
const existingTools = Array.isArray(body.tools) ? body.tools : []
|
||||
if (existingTools.some(hasComputerUseTool)) return body
|
||||
|
||||
return {
|
||||
...body,
|
||||
tools: [GEMINI_COMPUTER_USE_TOOL, ...existingTools],
|
||||
}
|
||||
}
|
||||
|
||||
function injectComputerUseToolIntoBody(body: BodyInit | null | undefined) {
|
||||
if (typeof body !== 'string') return body
|
||||
|
||||
try {
|
||||
return JSON.stringify(addGeminiComputerUseTool(JSON.parse(body)))
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
export function createGeminiComputerUseFetch(
|
||||
modelId: string,
|
||||
): typeof globalThis.fetch | undefined {
|
||||
if (!isGeminiComputerUseModel(modelId)) return undefined
|
||||
|
||||
const fetchWithComputerUse = (async (input, init) => {
|
||||
return globalThis.fetch(input, {
|
||||
...init,
|
||||
body: injectComputerUseToolIntoBody(init?.body),
|
||||
})
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
fetchWithComputerUse.preconnect = globalThis.fetch.preconnect.bind(
|
||||
globalThis.fetch,
|
||||
)
|
||||
|
||||
return fetchWithComputerUse
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { logger } from '../../logger'
|
||||
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
|
||||
import { createCodexFetch } from '../oauth/codex-fetch'
|
||||
import { createCopilotFetch } from '../oauth/copilot-fetch'
|
||||
import { createGeminiComputerUseFetch } from './gemini-computer-use-fetch'
|
||||
import {
|
||||
createMockBrowserOSLanguageModel,
|
||||
shouldUseMockBrowserOSLLM,
|
||||
@@ -42,12 +41,7 @@ function createOpenAIModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
|
||||
function createGoogleModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey) throw new Error('Google provider requires apiKey')
|
||||
const fetch = createGeminiComputerUseFetch(config.model)
|
||||
return createGoogleGenerativeAI({
|
||||
apiKey: config.apiKey,
|
||||
...(config.baseUrl && { baseURL: config.baseUrl }),
|
||||
...(fetch && { fetch }),
|
||||
})(config.model)
|
||||
return createGoogleGenerativeAI({ apiKey: config.apiKey })(config.model)
|
||||
}
|
||||
|
||||
function createOpenRouterModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Stats } from 'node:fs'
|
||||
import { mkdir as defaultMkdir, stat as defaultStat } from 'node:fs/promises'
|
||||
|
||||
interface EnsureDirectoryDeps {
|
||||
mkdir?: typeof defaultMkdir
|
||||
stat?: typeof defaultStat
|
||||
}
|
||||
|
||||
export async function ensureDirectory(
|
||||
path: string,
|
||||
deps: EnsureDirectoryDeps = {},
|
||||
): Promise<void> {
|
||||
const mkdir = deps.mkdir ?? defaultMkdir
|
||||
try {
|
||||
await mkdir(path, { recursive: true })
|
||||
return
|
||||
} catch (err) {
|
||||
if (!isAlreadyExistsError(err)) throw err
|
||||
const info = await statExistingDirectory(path, err, deps.stat)
|
||||
if (!info.isDirectory()) throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function statExistingDirectory(
|
||||
path: string,
|
||||
originalError: unknown,
|
||||
stat: typeof defaultStat = defaultStat,
|
||||
): Promise<Stats> {
|
||||
try {
|
||||
return await stat(path)
|
||||
} catch {
|
||||
throw originalError
|
||||
}
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
err.code === 'EEXIST'
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { getSoulPath } from './browseros-dir'
|
||||
|
||||
export const SOUL_TEMPLATE = `# SOUL.md — Who You Are
|
||||
const SOUL_TEMPLATE = `# SOUL.md — Who You Are
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
## Core Truths
|
||||
@@ -50,10 +50,6 @@ export async function writeSoul(content: string): Promise<WriteSoulResult> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetSoulTemplate(): Promise<WriteSoulResult> {
|
||||
return writeSoul(SOUL_TEMPLATE)
|
||||
}
|
||||
|
||||
export async function seedSoulTemplate(): Promise<void> {
|
||||
const file = Bun.file(getSoulPath())
|
||||
if (await file.exists()) return
|
||||
|
||||
@@ -24,8 +24,8 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
configureClaudeRuntime,
|
||||
configureCodexRuntime,
|
||||
configureHermesRuntime,
|
||||
getHermesRuntime,
|
||||
startHermesRuntimeBestEffort,
|
||||
} from './lib/agents/runtime'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
@@ -158,32 +158,7 @@ export class Application {
|
||||
})
|
||||
}
|
||||
|
||||
// Hermes container is also best-effort — same crash isolation
|
||||
// semantics as OpenClaw above. Image is pulled in the background;
|
||||
// an idle container is brought up so per-turn `nerdctl exec hermes acp`
|
||||
// calls from the harness don't pay container-create latency.
|
||||
try {
|
||||
const hermesRuntime = configureHermesRuntime({ resourcesDir })
|
||||
if (hermesRuntime) {
|
||||
void hermesRuntime.executeAction({ type: 'install' }).catch((err) =>
|
||||
logger.warn('Hermes prewarm failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
void hermesRuntime.executeAction({ type: 'start' }).catch((err) =>
|
||||
logger.warn('Hermes container start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Hermes container configuration failed, continuing without it',
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
startHermesRuntimeBestEffort({ resourcesDir })
|
||||
|
||||
metrics.log('http_server.started', { version: VERSION })
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { ensureDirectory } from '../../lib/ensure-directory'
|
||||
import { executeWithMetrics, toModelOutput } from './utils'
|
||||
|
||||
const TOOL_NAME = 'filesystem_write'
|
||||
@@ -20,7 +19,7 @@ export function createWriteTool(cwd: string) {
|
||||
execute: (params) =>
|
||||
executeWithMetrics(TOOL_NAME, async () => {
|
||||
const resolved = resolve(cwd, params.path)
|
||||
await ensureDirectory(dirname(resolved))
|
||||
await mkdir(dirname(resolved), { recursive: true })
|
||||
await writeFile(resolved, params.content, 'utf-8')
|
||||
const bytes = Buffer.byteLength(params.content, 'utf-8')
|
||||
return { text: `Wrote ${bytes} bytes to ${params.path}` }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mkdtemp, rename, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { ensureDirectory } from '../lib/ensure-directory'
|
||||
import { defineToolWithCategory, resolveWorkingPath } from './framework'
|
||||
|
||||
const pageParam = z.number().describe('Page ID (from list_pages)')
|
||||
@@ -125,7 +124,7 @@ export const download_file = defineCaptureTool({
|
||||
handler: async (args, ctx, response) => {
|
||||
const resolvedDir = resolveWorkingPath(ctx, args.path, args.cwd)
|
||||
const baseDir = ctx.directories.workingDir ?? tmpdir()
|
||||
await ensureDirectory(baseDir)
|
||||
await mkdir(baseDir, { recursive: true })
|
||||
const tempDir = await mkdtemp(join(baseDir, 'browseros-dl-'))
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { generateText, type LanguageModel } from 'ai'
|
||||
|
||||
import { createLanguageModel } from '../../src/agent/provider-factory'
|
||||
import {
|
||||
addGeminiComputerUseTool,
|
||||
isGeminiComputerUseModel,
|
||||
} from '../../src/lib/clients/llm/gemini-computer-use-fetch'
|
||||
import { createLLMProvider } from '../../src/lib/clients/llm/provider'
|
||||
|
||||
const COMPUTER_USE_MODEL = 'gemini-2.5-computer-use-preview-10-2025'
|
||||
|
||||
async function captureGoogleRequest(model: LanguageModel) {
|
||||
const originalFetch = globalThis.fetch
|
||||
let capturedInput: RequestInfo | URL | undefined
|
||||
let capturedBody: unknown
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedInput = input
|
||||
capturedBody = JSON.parse(String(init?.body))
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'ok' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 1,
|
||||
totalTokenCount: 2,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await generateText({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.ok(capturedInput)
|
||||
assert.ok(capturedBody)
|
||||
return {
|
||||
url: String(capturedInput),
|
||||
body: capturedBody as { tools?: unknown[] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('Gemini Computer Use provider requests', () => {
|
||||
it('detects Gemini Computer Use model ids', () => {
|
||||
expect(isGeminiComputerUseModel(COMPUTER_USE_MODEL)).toBe(true)
|
||||
expect(isGeminiComputerUseModel('gemini-2.5-pro')).toBe(false)
|
||||
})
|
||||
|
||||
it('adds Computer Use while preserving function declarations', () => {
|
||||
const body = addGeminiComputerUseTool({
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'custom_action',
|
||||
description: 'Custom action',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as { tools: unknown[] }
|
||||
|
||||
expect(body.tools).toEqual([
|
||||
{
|
||||
computerUse: {
|
||||
environment: 'ENVIRONMENT_BROWSER',
|
||||
},
|
||||
},
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'custom_action',
|
||||
description: 'Custom action',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('injects Computer Use for agent provider factory requests', async () => {
|
||||
const model = createLanguageModel({
|
||||
conversationId: 'test-conversation',
|
||||
provider: LLM_PROVIDERS.GOOGLE,
|
||||
model: COMPUTER_USE_MODEL,
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://proxy.example.test/v1beta',
|
||||
})
|
||||
|
||||
const request = await captureGoogleRequest(model)
|
||||
|
||||
expect(request.url).toBe(
|
||||
`https://proxy.example.test/v1beta/models/${COMPUTER_USE_MODEL}:generateContent`,
|
||||
)
|
||||
expect(request.body.tools?.[0]).toEqual({
|
||||
computerUse: {
|
||||
environment: 'ENVIRONMENT_BROWSER',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('injects Computer Use for lightweight LLM provider requests', async () => {
|
||||
const model = createLLMProvider({
|
||||
provider: LLM_PROVIDERS.GOOGLE,
|
||||
model: COMPUTER_USE_MODEL,
|
||||
apiKey: 'test-key',
|
||||
})
|
||||
|
||||
const request = await captureGoogleRequest(model)
|
||||
|
||||
expect(request.body.tools?.[0]).toEqual({
|
||||
computerUse: {
|
||||
environment: 'ENVIRONMENT_BROWSER',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves regular Gemini model requests unchanged', async () => {
|
||||
const model = createLanguageModel({
|
||||
conversationId: 'test-conversation',
|
||||
provider: LLM_PROVIDERS.GOOGLE,
|
||||
model: 'gemini-2.5-pro',
|
||||
apiKey: 'test-key',
|
||||
})
|
||||
|
||||
const request = await captureGoogleRequest(model)
|
||||
|
||||
expect(request.body.tools).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { existsSync, mkdtempSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { createMemoryRoutes } from '../../../src/api/routes/memory'
|
||||
import { createSoulRoutes } from '../../../src/api/routes/soul'
|
||||
import {
|
||||
getCoreMemoryPath,
|
||||
getMemoryDir,
|
||||
getSoulPath,
|
||||
} from '../../../src/lib/browseros-dir'
|
||||
|
||||
describe('memory and soul reset routes', () => {
|
||||
beforeEach(() => {
|
||||
process.env.BROWSEROS_DIR = mkdtempSync(
|
||||
join(tmpdir(), 'browseros-reset-routes-'),
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes all memory files and leaves the memory directory usable', async () => {
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await writeFile(getCoreMemoryPath(), 'core facts')
|
||||
await writeFile(join(getMemoryDir(), '2026-05-09.md'), 'daily notes')
|
||||
|
||||
const route = createMemoryRoutes()
|
||||
const response = await route.request('/', { method: 'DELETE' })
|
||||
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.deepStrictEqual(await response.json(), { success: true })
|
||||
assert.strictEqual(existsSync(getMemoryDir()), true)
|
||||
assert.strictEqual(existsSync(getCoreMemoryPath()), false)
|
||||
assert.strictEqual(existsSync(join(getMemoryDir(), '2026-05-09.md')), false)
|
||||
|
||||
const getResponse = await route.request('/')
|
||||
assert.deepStrictEqual(await getResponse.json(), { content: '' })
|
||||
})
|
||||
|
||||
it('resets SOUL.md to the default template', async () => {
|
||||
await mkdir(dirname(getSoulPath()), { recursive: true })
|
||||
await writeFile(getSoulPath(), '# Custom soul\nBe different.')
|
||||
|
||||
const route = createSoulRoutes()
|
||||
const response = await route.request('/', { method: 'DELETE' })
|
||||
|
||||
assert.strictEqual(response.status, 200)
|
||||
const body = await response.json()
|
||||
assert.strictEqual(body.truncated, false)
|
||||
assert.ok(body.linesWritten > 0)
|
||||
|
||||
const content = await readFile(getSoulPath(), 'utf8')
|
||||
assert.ok(
|
||||
content.includes("You're not a chatbot. You're becoming someone."),
|
||||
)
|
||||
assert.ok(!content.includes('Custom soul'))
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
|
||||
@@ -445,206 +446,179 @@ describe('AgentHarnessService', () => {
|
||||
})
|
||||
|
||||
it('writes a per-agent Hermes config.yaml + .env when adapter=hermes and provider config complete', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'sk-or-v1-test-key',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
const env = readFileSync(join(homeDir, '.env'), 'utf8')
|
||||
expect(yaml).toContain('"openrouter"')
|
||||
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when apiKey is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
}),
|
||||
).rejects.toThrow(/apiKey/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when providerType is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
|
||||
).rejects.toThrow(/providerType/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when modelId is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
|
||||
const agent = await service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'sk-or-v1-test-key',
|
||||
}),
|
||||
).rejects.toThrow(/modelId/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
const env = readFileSync(join(homeDir, '.env'), 'utf8')
|
||||
expect(yaml).toContain('"openrouter"')
|
||||
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when apiKey is missing', async () => {
|
||||
await withHermesBrowserosDir(async ({ agents, service }) => {
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
}),
|
||||
).rejects.toThrow(/apiKey/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when providerType is missing', async () => {
|
||||
await withHermesBrowserosDir(async ({ agents, service }) => {
|
||||
await expect(
|
||||
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
|
||||
).rejects.toThrow(/providerType/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when modelId is missing', async () => {
|
||||
await withHermesBrowserosDir(async ({ agents, service }) => {
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'sk-or-v1-test-key',
|
||||
}),
|
||||
).rejects.toThrow(/modelId/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('writes provider:custom + base_url for openai-compatible providers', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'Custom Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'my-model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
const env = readFileSync(join(homeDir, '.env'), 'utf8')
|
||||
// Hermes has no provider key called "openai" — the canonical shape
|
||||
// for any OpenAI-compatible endpoint is `provider: custom` with
|
||||
// `base_url` set. Hermes then short-circuits provider lookup and
|
||||
// calls the URL directly using OPENAI_API_KEY.
|
||||
expect(yaml).toContain('"custom"')
|
||||
expect(yaml).toContain('"my-model"')
|
||||
expect(yaml).toContain('"https://api.example.com/v1"')
|
||||
expect(env).toContain('OPENAI_API_KEY=sk-test')
|
||||
})
|
||||
|
||||
it('falls back to OpenAI default base_url for the openai provider type', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'OpenAI Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-openai-test',
|
||||
modelId: 'gpt-4o-mini',
|
||||
// No baseUrl supplied — provider:custom still requires one,
|
||||
// so the mapping's defaultBaseUrl must take over.
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
expect(yaml).toContain('"custom"')
|
||||
expect(yaml).toContain('"gpt-4o-mini"')
|
||||
expect(yaml).toContain('"https://api.openai.com/v1"')
|
||||
})
|
||||
|
||||
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
|
||||
const agent = await service.createAgent({
|
||||
name: 'Custom Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'my-model',
|
||||
}),
|
||||
).rejects.toThrow(/baseUrl/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
const env = readFileSync(join(homeDir, '.env'), 'utf8')
|
||||
// Hermes has no provider key called "openai" — the canonical shape
|
||||
// for any OpenAI-compatible endpoint is `provider: custom` with
|
||||
// `base_url` set. Hermes then short-circuits provider lookup and
|
||||
// calls the URL directly using OPENAI_API_KEY.
|
||||
expect(yaml).toContain('"custom"')
|
||||
expect(yaml).toContain('"my-model"')
|
||||
expect(yaml).toContain('"https://api.example.com/v1"')
|
||||
expect(env).toContain('OPENAI_API_KEY=sk-test')
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to OpenAI default base_url for the openai provider type', async () => {
|
||||
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
|
||||
const agent = await service.createAgent({
|
||||
name: 'OpenAI Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-openai-test',
|
||||
modelId: 'gpt-4o-mini',
|
||||
// No baseUrl supplied — provider:custom still requires one,
|
||||
// so the mapping's defaultBaseUrl must take over.
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
expect(yaml).toContain('"custom"')
|
||||
expect(yaml).toContain('"gpt-4o-mini"')
|
||||
expect(yaml).toContain('"https://api.openai.com/v1"')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
|
||||
await withHermesBrowserosDir(async ({ agents, service }) => {
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Custom Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'my-model',
|
||||
}),
|
||||
).rejects.toThrow(/baseUrl/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when providerType is not in the supported set', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
await withHermesBrowserosDir(async ({ agents, service }) => {
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Unknown Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'bedrock',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'm',
|
||||
}),
|
||||
).rejects.toThrow(/not supported/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Unknown Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'bedrock',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'm',
|
||||
}),
|
||||
).rejects.toThrow(/not supported/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
async function withHermesBrowserosDir<T>(
|
||||
run: (input: {
|
||||
agents: AgentDefinition[]
|
||||
browserosDir: string
|
||||
service: AgentHarnessService
|
||||
}) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const agents: AgentDefinition[] = []
|
||||
try {
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
browserosDir,
|
||||
runtime: stubRuntime(),
|
||||
})
|
||||
return await run({ agents, browserosDir, service })
|
||||
} finally {
|
||||
await rm(browserosDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function stubRuntime(): AgentRuntime {
|
||||
return {
|
||||
async status() {
|
||||
|
||||
@@ -984,7 +984,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
lockDir: stateDir,
|
||||
}
|
||||
const hermesRuntime = new HermesContainerRuntime(fakeManagedDeps, {
|
||||
browserosDir,
|
||||
hermesHarnessHostDir: join(browserosDir, 'vm', 'hermes', 'harness'),
|
||||
})
|
||||
getAgentRuntimeRegistry().register(hermesRuntime)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getHermesAgentHomeHostDir,
|
||||
getHermesHarnessHostDir,
|
||||
writeHermesPerAgentProvider,
|
||||
} from '../../../../src/lib/agents/hermes/hermes-paths'
|
||||
import { getHermesProviderMapping } from '../../../../src/lib/agents/hermes/hermes-provider-map'
|
||||
|
||||
describe('Hermes adapter helpers', () => {
|
||||
it('resolves Hermes state under the BrowserOS VM state root', () => {
|
||||
const browserosDir = '/tmp/browseros-test'
|
||||
|
||||
expect(getHermesHarnessHostDir(browserosDir)).toBe(
|
||||
'/tmp/browseros-test/vm/hermes/harness',
|
||||
)
|
||||
expect(
|
||||
getHermesAgentHomeHostDir({ browserosDir, agentId: 'agent-1' }),
|
||||
).toBe('/tmp/browseros-test/vm/hermes/harness/agent-1/home')
|
||||
})
|
||||
|
||||
it('writes per-agent provider config from the Hermes provider map', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-hermes-'))
|
||||
try {
|
||||
const mapping = getHermesProviderMapping('openai')
|
||||
expect(mapping).toEqual({
|
||||
hermesProvider: 'custom',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
defaultBaseUrl: 'https://api.openai.com/v1',
|
||||
})
|
||||
|
||||
await writeHermesPerAgentProvider({
|
||||
browserosDir,
|
||||
agentId: 'agent-1',
|
||||
providerId: mapping?.hermesProvider as string,
|
||||
envVarName: mapping?.envVarName as string,
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.5',
|
||||
baseUrl: mapping?.defaultBaseUrl,
|
||||
})
|
||||
|
||||
const home = getHermesAgentHomeHostDir({
|
||||
browserosDir,
|
||||
agentId: 'agent-1',
|
||||
})
|
||||
await expect(readFile(join(home, 'config.yaml'), 'utf8')).resolves.toBe(
|
||||
[
|
||||
'model:',
|
||||
' default: "gpt-5.5"',
|
||||
' provider: "custom"',
|
||||
' base_url: "https://api.openai.com/v1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
await expect(readFile(join(home, '.env'), 'utf8')).resolves.toBe(
|
||||
['OPENAI_API_KEY=sk-test', ''].join('\n'),
|
||||
)
|
||||
} finally {
|
||||
await rm(browserosDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
type OpenclawGatewayAccessor,
|
||||
resolveOpenclawAcpCommand,
|
||||
} from '../../../../src/lib/agents/openclaw/acp-command'
|
||||
|
||||
describe('resolveOpenclawAcpCommand', () => {
|
||||
const gateway: OpenclawGatewayAccessor = {
|
||||
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
|
||||
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
|
||||
getLimactlPath: () => '/Applications/BrowserOS.app/limactl',
|
||||
getVmName: () => 'browseros-vm',
|
||||
}
|
||||
|
||||
it('builds the in-gateway ACP bridge command', () => {
|
||||
const command = resolveOpenclawAcpCommand(gateway, 'agent:oc-123:main')
|
||||
|
||||
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
|
||||
expect(command).toContain(
|
||||
'/Applications/BrowserOS.app/limactl shell --workdir / browseros-vm --',
|
||||
)
|
||||
expect(command).toContain('nerdctl exec -i')
|
||||
expect(command).toContain('-e OPENCLAW_HIDE_BANNER=1')
|
||||
expect(command).toContain('-e OPENCLAW_SUPPRESS_NOTES=1')
|
||||
expect(command).toContain('browseros-openclaw-openclaw-gateway-1')
|
||||
expect(command).toContain(
|
||||
`openclaw acp --url ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`,
|
||||
)
|
||||
expect(command).toContain('--session agent:oc-123:main')
|
||||
})
|
||||
|
||||
it('maps legacy non-agent session keys onto the main gateway agent', () => {
|
||||
const command = resolveOpenclawAcpCommand(
|
||||
gateway,
|
||||
'openai-user:browseros:abc/def',
|
||||
)
|
||||
|
||||
expect(command).toContain(
|
||||
'--session agent:main:openai-user-browseros-abc-def',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
getHermesRuntime,
|
||||
HermesContainerRuntime,
|
||||
resetAgentRuntimeRegistry,
|
||||
startHermesRuntimeBestEffort,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
import type { RuntimeAction } from '../../../../src/lib/agents/runtime/types'
|
||||
import type {
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
@@ -105,7 +107,6 @@ describe('HermesContainerRuntime', () => {
|
||||
const browserosDir = extraConfig?.browserosDir ?? '/host/browseros'
|
||||
const { deps, getCapturedSpec } = makeDeps({ lockDir })
|
||||
const runtime = new HermesContainerRuntime(deps, {
|
||||
browserosDir,
|
||||
hermesHarnessHostDir: `${browserosDir}/vm/hermes/harness`,
|
||||
})
|
||||
return { runtime, getCapturedSpec, browserosDir }
|
||||
@@ -145,7 +146,6 @@ describe('HermesContainerRuntime', () => {
|
||||
},
|
||||
})
|
||||
const runtime = new HermesContainerRuntime(deps, {
|
||||
browserosDir: '/host/browseros',
|
||||
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
|
||||
})
|
||||
await runtime.start()
|
||||
@@ -157,7 +157,6 @@ describe('HermesContainerRuntime', () => {
|
||||
const lockDir = mkTempDir()
|
||||
const { deps } = makeDeps({ lockDir, exec: async () => 1 })
|
||||
const runtime = new HermesContainerRuntime(deps, {
|
||||
browserosDir: '/host/browseros',
|
||||
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
|
||||
})
|
||||
await expect(runtime.start()).rejects.toThrow(/probe failed/i)
|
||||
@@ -252,4 +251,77 @@ describe('HermesContainerRuntime', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startHermesRuntimeBestEffort', () => {
|
||||
it('configures Hermes and schedules install + start actions', async () => {
|
||||
const actions: RuntimeAction[] = []
|
||||
const runtime = {
|
||||
executeAction: async (action: RuntimeAction) => {
|
||||
actions.push(action)
|
||||
},
|
||||
} as HermesContainerRuntime
|
||||
|
||||
const result = startHermesRuntimeBestEffort({
|
||||
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
|
||||
configureRuntime: (options) => {
|
||||
expect(options).toEqual({
|
||||
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
|
||||
})
|
||||
return runtime
|
||||
},
|
||||
onError: (phase, error) => {
|
||||
throw new Error(`${phase}: ${String(error)}`)
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(runtime)
|
||||
expect(actions).toEqual([{ type: 'install' }, { type: 'start' }])
|
||||
})
|
||||
|
||||
it('returns null when Hermes configuration throws', () => {
|
||||
const errors: Array<{ phase: string; message: string }> = []
|
||||
|
||||
const result = startHermesRuntimeBestEffort({
|
||||
configureRuntime: () => {
|
||||
throw new Error('unsupported')
|
||||
},
|
||||
onError: (phase, error) => {
|
||||
errors.push({
|
||||
phase,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(errors).toEqual([{ phase: 'configure', message: 'unsupported' }])
|
||||
})
|
||||
|
||||
it('reports install and start failures without throwing', async () => {
|
||||
const errors: Array<{ phase: string; message: string }> = []
|
||||
const runtime = {
|
||||
executeAction: async (action: RuntimeAction) => {
|
||||
throw new Error(`${action.type} failed`)
|
||||
},
|
||||
} as HermesContainerRuntime
|
||||
|
||||
const result = startHermesRuntimeBestEffort({
|
||||
configureRuntime: () => runtime,
|
||||
onError: (phase, error) => {
|
||||
errors.push({
|
||||
phase,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(runtime)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(errors).toEqual([
|
||||
{ phase: 'install', message: 'install failed' },
|
||||
{ phase: 'start', message: 'start failed' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { ensureDirectory } from '../../src/lib/ensure-directory'
|
||||
|
||||
describe('ensureDirectory', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('creates missing nested directories', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'browseros-ensure-dir-'))
|
||||
tempDirs.push(root)
|
||||
const target = join(root, 'OneDrive', 'South Hills OS')
|
||||
|
||||
await ensureDirectory(target)
|
||||
|
||||
expect((await stat(target)).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it('treats EEXIST as success when the requested directory exists', async () => {
|
||||
const target = 'C:\\Users\\user\\OneDrive\\South Hills OS'
|
||||
const eexist = Object.assign(
|
||||
new Error(
|
||||
"EEXIST: file already exists, mkdir 'C:\\Users\\user\\OneDrive'",
|
||||
),
|
||||
{ code: 'EEXIST', path: 'C:\\Users\\user\\OneDrive' },
|
||||
)
|
||||
let statPath: string | undefined
|
||||
|
||||
await ensureDirectory(target, {
|
||||
mkdir: (async () => {
|
||||
throw eexist
|
||||
}) as typeof import('node:fs/promises').mkdir,
|
||||
stat: (async (path: string) => {
|
||||
statPath = path
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
} as Stats
|
||||
}) as typeof import('node:fs/promises').stat,
|
||||
})
|
||||
|
||||
expect(statPath).toBe(target)
|
||||
})
|
||||
|
||||
it('does not hide EEXIST when the requested path is not a directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'browseros-ensure-dir-'))
|
||||
tempDirs.push(root)
|
||||
const target = join(root, 'not-a-dir')
|
||||
await writeFile(target, 'file')
|
||||
|
||||
await expect(ensureDirectory(target)).rejects.toThrow(/EEXIST|ENOTDIR/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user