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