Compare commits

..

4 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
Nikhil
d7e1125db3 fix(server): tolerate existing workspace dirs
Fixes #974
2026-05-08 19:17:29 -07:00
Nikhil
8b6483a633 fix(server): support Gemini computer use requests
Fixes #148
2026-05-08 19:12:07 -07:00
52 changed files with 668 additions and 1150 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

@@ -57,7 +57,6 @@ const formSchema = z
scheduleTime: z.string().optional(),
scheduleInterval: z.number().int().min(1).max(60).optional(),
providerId: z.string().optional(),
runSilently: z.boolean(),
enabled: z.boolean(),
})
.superRefine((data, ctx) => {
@@ -108,7 +107,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleTime: '09:00',
scheduleInterval: 1,
providerId: undefined,
runSilently: true,
enabled: true,
},
})
@@ -146,7 +144,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleTime: initialValues.scheduleTime || '09:00',
scheduleInterval: initialValues.scheduleInterval || 1,
providerId: initialValues.providerId,
runSilently: initialValues.runSilently ?? true,
enabled: initialValues.enabled,
})
} else {
@@ -157,7 +154,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleTime: '09:00',
scheduleInterval: 1,
providerId: undefined,
runSilently: true,
enabled: true,
})
}
@@ -257,7 +253,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
scheduleInterval:
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
providerId: values.providerId,
runSilently: values.runSilently,
enabled: values.enabled,
})
form.reset()
@@ -463,28 +458,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
)}
</div>
<FormField
control={form.control}
name="runSilently"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1">
<FormLabel className="font-normal">Run silently</FormLabel>
<FormDescription>
Use a hidden background page without opening or focusing a
browser window.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"

View File

@@ -149,10 +149,6 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
</span>
</>
)}
<span></span>
<span>
{job.runSilently === false ? 'Visible run' : 'Silent run'}
</span>
{job.lastRunAt && (
<>
<span></span>

View File

@@ -71,7 +71,6 @@ export const ScheduledTasksPage: FC = () => {
'daily',
scheduleTime: searchParams.get('scheduleTime') ?? '09:00',
scheduleInterval: 1,
runSilently: true,
enabled: true,
createdAt: '',
updatedAt: '',

View File

@@ -118,7 +118,6 @@ export const scheduledJobRuns = async () => {
message: job.query,
signal: abortController.signal,
providerId: job.providerId,
runSilently: job.runSilently ?? true,
})
await updateJobRun(jobRun.id, {

View File

@@ -37,7 +37,7 @@ export const TIPS: Tip[] = [
},
{
id: 'background-tasks',
text: 'Scheduled tasks can run silently in the background without opening a new browser window.',
text: 'Scheduled tasks run in a separate window so they never interrupt your browsing.',
},
{
id: 'claude-code-mcp',

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

@@ -81,16 +81,4 @@ describe('buildChatRequestBody', () => {
expect(body.toolApprovalConfig).toBeUndefined()
})
it('passes scheduled task silent mode through to the server', () => {
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
isScheduledTask: true,
runSilently: false,
})
expect(body.isScheduledTask).toBe(true)
expect(body.runSilently).toBe(false)
})
})

View File

@@ -53,7 +53,6 @@ interface ChatRequestBodyParams {
toolApprovalConfig?: ToolApprovalConfig
toolApprovalResponses?: ApprovalResponseData[]
isScheduledTask?: boolean
runSilently?: boolean
}
export const toRequestToolApprovalConfig = (
@@ -82,7 +81,6 @@ export const buildChatRequestBody = ({
toolApprovalConfig,
toolApprovalResponses,
isScheduledTask,
runSilently,
}: ChatRequestBodyParams) => ({
message,
provider: provider.type,
@@ -114,5 +112,4 @@ export const buildChatRequestBody = ({
toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig),
toolApprovalResponses,
isScheduledTask,
runSilently,
})

View File

@@ -27,7 +27,6 @@ interface ChatServerRequest {
activeTab?: ActiveTab
signal?: AbortSignal
providerId?: string
runSilently?: boolean
}
interface ChatServerResponse {
@@ -138,7 +137,6 @@ export async function getChatServerResponse(
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
supportsImages: provider.supportsImages,
isScheduledTask: true,
runSilently: request.runSilently ?? true,
}),
}),
})

View File

@@ -6,7 +6,6 @@ export interface ScheduledJob {
scheduleTime?: string
scheduleInterval?: number
enabled: boolean
runSilently?: boolean
providerId?: string
createdAt: string
updatedAt: string

View File

@@ -26,7 +26,7 @@ type RemoteScheduledJob = {
lastRunAt: string | null
}
const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt', 'runSilently'] as const
const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt'] as const
function toComparable(job: ScheduledJob) {
const data = omit(job, IGNORED_FIELDS)
@@ -63,7 +63,6 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
scheduleTime: remote.scheduleTime ?? undefined,
scheduleInterval: remote.scheduleInterval ?? undefined,
enabled: remote.enabled,
runSilently: true,
providerId: remote.llmProviderId ?? undefined,
createdAt: normalizeTimestamp(remote.createdAt),
updatedAt: normalizeTimestamp(remote.updatedAt),

View File

@@ -224,7 +224,6 @@ export class AiSdkAgent {
userSystemPrompt: config.resolvedConfig.userSystemPrompt,
exclude: excludeSections,
isScheduledTask: config.resolvedConfig.isScheduledTask,
scheduledTaskRunSilently: config.resolvedConfig.scheduledTaskRunSilently,
scheduledTaskPageId: config.browserContext?.activeTab?.pageId,
workspaceDir: config.resolvedConfig.workingDir,
soulContent,

View File

@@ -48,11 +48,8 @@ You do not have a filesystem workspace in this session. Return all results direc
// Mode-aware framing
if (options?.isScheduledTask) {
const location =
options.scheduledTaskRunSilently !== false
? ' on a system-managed hidden page'
: ''
role += `\n\nYou are running as a scheduled background task${location}. Complete the task autonomously and report results.`
role +=
'\n\nYou are running as a scheduled background task on a system-managed hidden page. Complete the task autonomously and report results.'
} else if (options?.chatMode) {
role +=
'\n\nYou are in read-only chat mode. You can observe pages but cannot interact with them, modify files, or store memories.'
@@ -662,13 +659,9 @@ function getUserContext(
if (!options?.chatMode) {
let pageCtx = '<page_context>'
const isSilentScheduledTask =
options?.isScheduledTask && options.scheduledTaskRunSilently !== false
if (options?.isScheduledTask) {
pageCtx += isSilentScheduledTask
? '\nYou are running as a **scheduled background task** on a system-managed hidden page.'
: '\nYou are running as a **scheduled background task**.'
pageCtx +=
'\nYou are running as a **scheduled background task** on a system-managed hidden page.'
}
pageCtx +=
@@ -678,21 +671,14 @@ function getUserContext(
const pageRef = options.scheduledTaskPageId
? `\`${options.scheduledTaskPageId}\``
: 'the page ID from the Browser Context'
if (isSilentScheduledTask) {
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.`
pageCtx +=
'\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.'
pageCtx +=
'\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.'
pageCtx +=
'\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.'
pageCtx += '\n6. Complete the task end-to-end and report results.'
} else {
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_page\` with background mode so the task does not steal focus.`
pageCtx +=
'\n3. **Do NOT create new windows** unless the scheduled task explicitly requires a separate window.'
pageCtx += '\n4. Complete the task end-to-end and report results.'
}
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.`
pageCtx +=
'\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.'
pageCtx +=
'\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.'
pageCtx +=
'\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.'
pageCtx += '\n6. Complete the task end-to-end and report results.'
}
pageCtx += '\n</page_context>'
@@ -753,7 +739,6 @@ export interface BuildSystemPromptOptions {
userSystemPrompt?: string
exclude?: string[]
isScheduledTask?: boolean
scheduledTaskRunSilently?: boolean
scheduledTaskPageId?: number
workspaceDir?: string
soulContent?: string

View File

@@ -45,8 +45,6 @@ export interface ResolvedAgentConfig {
chatMode?: boolean
/** Scheduled task mode - disables tab grouping. Defaults to false. */
isScheduledTask?: boolean
/** Scheduled task silent mode - runs on hidden pages without visible windows. Defaults to true. */
scheduledTaskRunSilently?: boolean
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
declinedApps?: string[]
/** Where the chat session originates from — determines navigation behavior. */

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

@@ -62,7 +62,6 @@ export class ChatService {
supportsImages: request.supportsImages,
chatMode: request.mode === 'chat',
isScheduledTask: request.isScheduledTask,
scheduledTaskRunSilently: request.runSilently,
origin: request.origin,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
@@ -187,7 +186,7 @@ export class ChatService {
this.deps.browser,
request.browserContext,
)
if (request.isScheduledTask && request.runSilently !== false) {
if (request.isScheduledTask) {
try {
hiddenPageId = await this.deps.browser.newPage('about:blank', {
hidden: true,

View File

@@ -41,7 +41,6 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
browserContext: BrowserContextSchema.optional(),
userSystemPrompt: z.string().optional(),
isScheduledTask: z.boolean().optional().default(false),
runSilently: z.boolean().optional().default(true),
userWorkingDir: z.string().min(1).optional(),
supportsImages: z.boolean().optional().default(true),
mode: z.enum(['chat', 'agent']).optional().default('agent'),

View File

@@ -517,15 +517,45 @@ export class Browser {
return null
}
private async resolveWindowIdForNewPage(opts?: {
hidden?: boolean
windowId?: number
}): Promise<number | undefined> {
if (!opts?.hidden) {
return opts?.windowId
}
if (opts.windowId !== undefined) {
const windows = await this.listWindows()
const targetWindow = windows.find(
(window) => window.windowId === opts.windowId,
)
if (targetWindow && !targetWindow.isVisible) {
return targetWindow.windowId
}
if (targetWindow?.isVisible) {
logger.warn(
'Requested hidden page target window is visible, creating a new hidden window instead',
{
requestedWindowId: opts.windowId,
},
)
}
}
const hiddenWindow = await this.createWindow({ hidden: true })
return hiddenWindow.windowId
}
async newPage(
url: string,
opts?: { hidden?: boolean; background?: boolean; windowId?: number },
): Promise<number> {
const windowId = await this.resolveWindowIdForNewPage(opts)
const createResult = await this.cdp.Browser.createTab({
url,
...(opts?.background !== undefined && { background: opts.background }),
...(opts?.hidden !== undefined && { hidden: opts.hidden }),
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
...(windowId !== undefined && { windowId }),
})
const tabId = (createResult.tab as TabInfo).tabId
@@ -553,7 +583,7 @@ export class Browser {
loadProgress: tabInfo.loadProgress,
isPinned: tabInfo.isPinned,
isHidden: tabInfo.isHidden,
windowId: tabInfo.windowId ?? opts?.windowId,
windowId: tabInfo.windowId ?? windowId,
index: tabInfo.index,
groupId: tabInfo.groupId,
})

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

@@ -326,14 +326,6 @@ describe('mode-aware framing', () => {
expect(prompt).toContain('Do NOT create new windows')
expect(prompt).toContain('Close extra hidden pages')
})
it('visible scheduled task mode excludes hidden page rules', () => {
const prompt = buildScheduled({ scheduledTaskRunSilently: false })
expect(prompt).toContain('scheduled background task')
expect(prompt).not.toContain('system-managed hidden page')
expect(prompt).not.toContain('Do NOT close your starting hidden page')
expect(prompt).toContain('new_page')
})
})
// ---------------------------------------------------------------------------

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

@@ -291,68 +291,6 @@ describe('ChatService scheduled task hidden page lifecycle', () => {
})
expect(browser.closePage).toHaveBeenCalledWith(88)
})
it('uses the provided visible context when a scheduled task disables silent mode', async () => {
const fakeAgent = createFakeAgent()
agentToReturn = fakeAgent
streamResponseHandler = async ({ onFinish, uiMessages }) => {
await onFinish({ messages: uiMessages ?? fakeAgent.messages })
return new Response('ok')
}
const browser = {
newPage: mock(async () => 99),
listPages: mock(async () => []),
closePage: mock(async () => {}),
resolveTabIds: mock(async () => new Map([[3, 103]])),
}
const sessionStore = createSessionStore()
const service = new ChatService({
sessionStore: sessionStore as never,
klavisRef: { handle: null },
browser: browser as never,
registry: {} as never,
})
await service.processMessage(
{
conversationId: crypto.randomUUID(),
message: 'Run the scheduled task visibly',
isScheduledTask: true,
runSilently: false,
mode: 'agent',
origin: 'sidepanel',
browserContext: {
activeTab: {
id: 3,
url: 'https://example.com',
title: 'Example',
},
},
} as never,
new AbortController().signal,
)
expect(browser.newPage).not.toHaveBeenCalled()
expect(browser.closePage).not.toHaveBeenCalled()
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
browserContext?: {
activeTab?: {
id: number
pageId?: number
url: string
title: string
}
}
}
expect(createArgs.browserContext?.activeTab).toEqual({
id: 3,
pageId: 103,
url: 'https://example.com',
title: 'Example',
})
})
})
describe('ChatService Klavis session rebuilds', () => {

View File

@@ -1,57 +0,0 @@
import { describe, expect, it, mock } from 'bun:test'
mock.module('../../src/lib/logger', () => ({
logger: {
debug: mock(() => {}),
error: mock(() => {}),
info: mock(() => {}),
warn: mock(() => {}),
},
}))
const { Browser } = await import('../../src/browser/browser')
describe('Browser', () => {
it('creates hidden pages as hidden tabs without opening a new window', async () => {
const tab = {
tabId: 10,
targetId: 'target-10',
url: 'about:blank',
title: '',
isActive: false,
isLoading: false,
loadProgress: 1,
isPinned: false,
isHidden: true,
index: 0,
}
const cdp = {
isConnected: () => true,
onSessionEvent: mock(() => () => {}),
Target: {
on: mock(() => {}),
},
Browser: {
createTab: mock(async () => ({ tab })),
getTabInfo: mock(async () => ({ tab })),
createWindow: mock(async () => {
throw new Error('createWindow should not be called for hidden pages')
}),
},
}
const browser = new Browser(cdp as never)
const pageId = await browser.newPage('about:blank', {
hidden: true,
background: true,
})
expect(pageId).toBe(1)
expect(cdp.Browser.createTab).toHaveBeenCalledWith({
url: 'about:blank',
background: true,
hidden: true,
})
expect(cdp.Browser.createWindow).not.toHaveBeenCalled()
})
})

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