Compare commits

...

2 Commits

Author SHA1 Message Date
Nikhil
0be59dccdd fix: revert recent agent/server changes (#995)
* Revert "fix(server): tolerate existing workspace dirs"

This reverts commit d7e1125db3.

* Revert "fix(server): support Gemini computer use requests"

This reverts commit 8b6483a633.

* Revert "feat(agent): add reset controls for sessions and memory"

This reverts commit f54eff4543.

* Revert "fix: add cloud sync sign-in disclosure"

This reverts commit f1ebfa5232.

* Revert "fix: allow pasted images in agent text box"

This reverts commit b89ea201fa.

* fix(server): stabilize Hermes harness state paths

* fix: address review feedback for PR #995
2026-05-11 14:26:56 -07:00
shivammittal274
dad2331448 refactor(agent): clean up hermes adapter structure (#994) 2026-05-11 22:57:59 +05:30
43 changed files with 641 additions and 1307 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,8 @@ export {
HermesContainerRuntime,
type HermesContainerRuntimeConfig,
prepareHermesContext,
type StartHermesRuntimeBestEffortOptions,
startHermesRuntimeBestEffort,
} from './hermes-container-runtime'
export {
HostProcessAgentRuntime,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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